Repository: amp-labs/connectors
Pull request #945 (2024-08-20 10:40:11)

Feat: Enable Mixmax proxy
## Testing 
### GET 
URL: <https://proxy.withampersand.com/v1/insightsreports> 
![Screenshot 2567-08-20 at 17 20 34](https://github.com/user-attachments/assets/c24a5317-b8f6-4cac-a23c-76692cf78d9c)

### POST
URL: <https://proxy.withampersand.com/v1/insightsreports> 
![Screenshot 2567-08-20 at 17 23 33](https://github.com/user-attachments/assets/601e64b3-81c3-405d-b3e9-71fee7e9b515)

### PATCH
URL: <https://proxy.withampersand.com/v1/insightsreports/66c46e9ad1c29a82e3c8c198> 
![Screenshot 2567-08-20 at 17 24 19](https://github.com/user-attachments/assets/1c8334cb-46df-4854-89a8-036bfa3f1dfe)

### DELETE
URL: <https://proxy.withampersand.com/v1/insightsreports/66c46e9ad1c29a82e3c8c198> 
![Screenshot 2567-08-20 at 17 24 52](https://github.com/user-attachments/assets/bac0bc97-d4b6-4680-be45-3f04abbee968)

### Invalid requests
**Wrong path**
![Screenshot 2567-08-20 at 17 25 54](https://github.com/user-attachments/assets/68d8f984-e6d6-4652-adfe-61f02bf1910a)

**Wrong method**
![Screenshot 2567-08-20 at 17 25 30](https://github.com/user-attachments/assets/01737edb-31d4-4a59-9d9e-8b900865907d)

## Pagination
**Without pagination** 
![Screenshot 2567-08-20 at 17 20 34](https://github.com/user-attachments/assets/21f51d50-e536-47d3-89d9-1b0907d5877d)

**With pagination using `limit` and `next` parameters**
![Screenshot 2567-08-20 at 17 21 35](https://github.com/user-attachments/assets/8de7eefc-7701-41b7-a383-a2157ad2ee6c)

## Successful console operation (operation & events)
![Screenshot 2567-08-20 at 17 26 19](https://github.com/user-attachments/assets/e97a05f1-8d0c-4f9e-8706-5ee90b1af40c)

## Failed console operation (operation & events)
![Screenshot 2567-08-20 at 17 26 09](https://github.com/user-attachments/assets/d07afeb1-1dcb-4035-b79a-2089b348e3f4)

## Raw token response
Can't print the token as the connector supports API key type of authentication


Patch:
diff --git a/common/http.go b/common/http.go
index fb862f49..ea08ec35 100644
--- a/common/http.go
+++ b/common/http.go
@@ -26,11 +26,14 @@ type Header struct {
 // to the error handler as arguments.
 type ErrorHandler func(rsp *http.Response, body []byte) error
 
+type ResponseHandler func(rsp *http.Response) (*http.Response, error)
+
 // HTTPClient is an HTTP client that handles OAuth access token refreshes.
 type HTTPClient struct {
-	Base         string                  // optional base URL. If not set, then all URLs must be absolute.
-	Client       AuthenticatedHTTPClient // underlying HTTP client. Required.
-	ErrorHandler ErrorHandler            // optional error handler. If not set, then the default error handler is used.
+	Base            string                  // optional base URL. If not set, then all URLs must be absolute.
+	Client          AuthenticatedHTTPClient // underlying HTTP client. Required.
+	ErrorHandler    ErrorHandler            // optional error handler. If not set, then the default error handler is used.
+	ResponseHandler ResponseHandler         // optional, Allows mutation of the http.Response from the Saas API response.
 }
 
 // getURL returns the base prefixed URL.
@@ -261,13 +264,21 @@ func makeDeleteRequest(ctx context.Context, url string, headers []Header) (*http
 }
 
 // sendRequest sends the given request and returns the response & response body.
-func (h *HTTPClient) sendRequest(req *http.Request) (*http.Response, []byte, error) {
+func (h *HTTPClient) sendRequest(req *http.Request) (*http.Response, []byte, error) { //nolint:cyclop
 	// Send the request
 	res, err := h.Client.Do(req)
 	if err != nil {
 		return nil, nil, err
 	}
 
+	// Apply the ResponseHandler if provided
+	if h.ResponseHandler != nil {
+		res, err = h.ResponseHandler(res)
+		if err != nil {
+			return nil, nil, err
+		}
+	}
+
 	// Read the response body
 	body, err := io.ReadAll(res.Body)
 
diff --git a/marketo/client.go b/marketo/client.go
new file mode 100644
index 00000000..c77e7775
--- /dev/null
+++ b/marketo/client.go
@@ -0,0 +1,16 @@
+package marketo
+
+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/marketo/connnector.go b/marketo/connnector.go
new file mode 100644
index 00000000..8161266f
--- /dev/null
+++ b/marketo/connnector.go
@@ -0,0 +1,72 @@
+package marketo
+
+import (
+	"strings"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/providers"
+)
+
+type Connector struct {
+	BaseURL string
+	Client  *common.JSONHTTPClient
+	Module  string
+}
+
+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
+	}
+
+	// Read provider info
+	providerInfo, err := providers.ReadInfo(providers.Marketo, &params.Workspace)
+	if err != nil {
+		return nil, err
+	}
+
+	conn = &Connector{
+		Client: &common.JSONHTTPClient{
+			HTTPClient: &common.HTTPClient{
+				Client:          params.Caller.Client,
+				ResponseHandler: responseHandler,
+			},
+		},
+		Module: params.Module.Name,
+	}
+
+	conn.setBaseURL(providerInfo.BaseURL)
+
+	return conn, nil
+}
+
+func (c *Connector) setBaseURL(newURL string) {
+	c.BaseURL = newURL
+	c.Client.HTTPClient.Base = newURL
+}
+
+// Provider returns the connector provider.
+func (c *Connector) Provider() providers.Provider {
+	return providers.Marketo
+}
+
+func (c *Connector) getApiURL(objName string) (*urlbuilder.URL, error) {
+	bURL := strings.Join([]string{restAPIPrefix, c.Module, objName}, "/")
+
+	if !strings.HasSuffix(objName, ".json") {
+		bURL += ".json"
+	}
+
+	return constructURL(c.BaseURL, bURL)
+}
+
+func (c *Connector) String() string {
+	return c.Provider() + ".Connector"
+}
diff --git a/marketo/errors.go b/marketo/errors.go
new file mode 100644
index 00000000..1421baf9
--- /dev/null
+++ b/marketo/errors.go
@@ -0,0 +1,137 @@
+package marketo
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"strconv"
+)
+
+type Error struct {
+	Code    string `json:"code"`
+	Message string `json:"message"`
+}
+
+type Response struct {
+	Errors    []Error          `json:"errors"`
+	Result    []map[string]any `json:"result"`
+	RequestID string           `json:"requestId"`
+	Success   bool             `json:"success"`
+}
+
+// checkResponseLeverErr reports wheather the response level error is available or not.
+// If available, returns the error code as well.
+func checkResponseLeverErr(body []byte) (bool, int, error) {
+	var resp Response
+	if err := json.Unmarshal(body, &resp); err != nil {
+		return false, 0, err
+	}
+
+	if len(resp.Errors) == 0 {
+		return false, 0, nil
+	}
+
+	code, err := strconv.Atoi(resp.Errors[0].Code)
+	if err != nil {
+		return false, 0, err
+	}
+
+	return (len(resp.Errors) > 0), code, nil
+}
+
+func responseHandler(resp *http.Response) (*http.Response, error) { //nolint:cyclop
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
+		erroneous, code, err := checkResponseLeverErr(body)
+		if err != nil {
+			return nil, err
+		}
+
+		// If response is 200 OK, but erroneous, we update the status code,
+		//  continue with the switch cases.
+		if erroneous {
+			statusCode := statusCodeMap(code)
+			resp.StatusCode = statusCode
+		}
+	}
+
+	// reset body.
+	resp.Body = io.NopCloser(bytes.NewBuffer(body))
+
+	return resp, nil
+}
+
+// statusCodeMap maps the erroneous response from marketo, with a valid http status code.
+// The response body can be sent as is.
+// https://experienceleague.adobe.com/en/docs/marketo-developer/marketo/rest/error-codes
+//
+// nolint
+func statusCodeMap(code int) int {
+	switch code {
+	case 502:
+		return http.StatusBadGateway
+	case 601:
+		return http.StatusUnauthorized
+	case 602:
+		return http.StatusUnauthorized // token Expired
+	case 603:
+		return http.StatusForbidden
+	case 604:
+		return http.StatusRequestTimeout
+	case 605:
+		return http.StatusMethodNotAllowed
+	case 606:
+		return http.StatusTooManyRequests
+	case 607:
+		return http.StatusTooManyRequests
+	case 608:
+		return http.StatusServiceUnavailable
+	case 609:
+		return http.StatusBadRequest
+	case 610:
+		return http.StatusNotFound
+	case 611:
+		return http.StatusInternalServerError
+	case 612:
+		return http.StatusUnsupportedMediaType
+	case 613:
+		return http.StatusBadRequest
+	case 614:
+		return http.StatusNotFound
+	case 615:
+		return http.StatusTooManyRequests
+	case 616:
+		return http.StatusForbidden
+	case 701:
+		return http.StatusBadRequest
+	case 702:
+		return http.StatusNotFound
+	case 703:
+		return http.StatusForbidden
+	case 704:
+		return http.StatusBadRequest
+	case 709:
+		return http.StatusConflict
+	case 710:
+		return http.StatusNotFound
+	case 711:
+		return http.StatusBadRequest
+	case 712:
+		return http.StatusBadRequest
+	case 713:
+		return http.StatusServiceUnavailable
+	case 714:
+		return http.StatusNotFound
+	case 718:
+		return http.StatusNotFound
+	case 719:
+		return http.StatusRequestTimeout
+	default:
+		return code
+	}
+}
diff --git a/marketo/module.go b/marketo/module.go
new file mode 100644
index 00000000..1361e9d4
--- /dev/null
+++ b/marketo/module.go
@@ -0,0 +1,22 @@
+package marketo
+
+import "github.com/amp-labs/connectors/common/paramsbuilder"
+
+// ModuleAssets is the module/API used for accessing assets objects.
+var ModuleAssets = paramsbuilder.APIModule{ // nolint: gochecknoglobals
+	Label:   "asset",
+	Version: "v1",
+}
+
+// ModuleLeads is the module/API used for accessing leads objects.
+var ModuleLeads = paramsbuilder.APIModule{ //nolint: gochecknoglobals
+	Label:   "",
+	Version: "v1",
+}
+
+// supportedModules represents currently working and supported modules within the Marketo connector.
+// Any added module should be appended here.
+var supportedModules = []paramsbuilder.APIModule{ // nolint: gochecknoglobals
+	ModuleLeads,
+	ModuleAssets,
+}
diff --git a/marketo/params.go b/marketo/params.go
new file mode 100644
index 00000000..141a9e2b
--- /dev/null
+++ b/marketo/params.go
@@ -0,0 +1,57 @@
+package marketo
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+	"golang.org/x/oauth2/clientcredentials"
+)
+
+type Option = func(params *parameters)
+
+type parameters struct {
+	paramsbuilder.Client
+	paramsbuilder.Workspace
+	paramsbuilder.Module
+}
+
+func (p parameters) ValidateParams() error {
+	return errors.Join(
+		p.Client.ValidateParams(),
+		p.Workspace.ValidateParams(),
+		p.Module.ValidateParams(),
+	)
+}
+
+func WithClient(ctx context.Context, client *http.Client,
+	config *clientcredentials.Config,
+) Option {
+	authClient := config.Client(ctx)
+
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(authClient)
+	}
+}
+
+// WithModule sets the marketo API module to use for the connector. It's required.
+func WithModule(module paramsbuilder.APIModule) Option {
+	return func(params *parameters) {
+		params.WithModule(module, supportedModules, &ModuleLeads)
+	}
+}
+
+// WithWorkspace sets the marketo API instance to use for the connector. It's required.
+func WithWorkspace(workspace string) Option {
+	return func(params *parameters) {
+		params.WithWorkspace(workspace)
+	}
+}
+
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
+	}
+}
diff --git a/marketo/parse.go b/marketo/parse.go
new file mode 100644
index 00000000..157f307f
--- /dev/null
+++ b/marketo/parse.go
@@ -0,0 +1,40 @@
+package marketo
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+// getNextRecordsURL returns the URL for the next page of results.
+func getNextRecordsURL(node *ajson.Node) (string, error) {
+	return jsonquery.New(node).StrWithDefault("nextPageToken", "")
+}
+
+// getRecords returns the records from the response.
+func getRecords(node *ajson.Node) ([]map[string]any, error) {
+	result, err := jsonquery.New(node).Array("result", true)
+	if err != nil {
+		return nil, err
+	}
+
+	return jsonquery.Convertor.ArrayToMap(result)
+}
+
+func getTotalSize(node *ajson.Node) (int64, error) {
+	return jsonquery.New(node).ArraySize("result")
+}
+
+// getMarshalledData accepts a list of records and returns a list of structured data ([]ReadResultRow).
+func getMarshalledData(records []map[string]any, fields []string) ([]common.ReadResultRow, error) {
+	data := make([]common.ReadResultRow, len(records))
+
+	for i, record := range records {
+		data[i] = common.ReadResultRow{
+			Fields: common.ExtractLowercaseFieldsFromRaw(fields, record),
+			Raw:    record,
+		}
+	}
+
+	return data, nil
+}
diff --git a/marketo/read.go b/marketo/read.go
new file mode 100644
index 00000000..03e104a3
--- /dev/null
+++ b/marketo/read.go
@@ -0,0 +1,29 @@
+package marketo
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+// Read retrieves data based on the provided configuration parameters.
+//
+// This function executes a read operation using the given context and.
+func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
+	url, err := c.getURL(config)
+	if err != nil {
+		return nil, err
+	}
+
+	res, err := c.Client.Get(ctx, url.String())
+	if err != nil {
+		return nil, err
+	}
+
+	return common.ParseResult(res, getTotalSize,
+		getRecords,
+		getNextRecordsURL,
+		getMarshalledData,
+		config.Fields,
+	)
+}
diff --git a/marketo/url.go b/marketo/url.go
new file mode 100644
index 00000000..1c61aafd
--- /dev/null
+++ b/marketo/url.go
@@ -0,0 +1,43 @@
+package marketo
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+)
+
+var restAPIPrefix string = "rest" //nolint:gochecknoglobals
+
+func (c *Connector) getURL(params common.ReadParams) (*urlbuilder.URL, error) {
+	// If NextPage is set, then we're reading the next page of results.
+	// The NextPage URL has all the necessary parameters.
+	if len(params.NextPage) > 0 {
+		return constructURL(params.NextPage.String())
+	}
+
+	link, err := c.getApiURL(params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	// This affects  a very few number of objects.
+	// Leads, Deleted Leads, Lead Changes,
+	if !params.Since.IsZero() {
+		time := params.Since.Format(time.RFC3339)
+		fmtTime := fmt.Sprintf("%v", time)
+		link.WithQueryParam("sinceDatetime", fmtTime)
+	}
+
+	return link, nil
+}
+
+func constructURL(base string, path ...string) (*urlbuilder.URL, error) {
+	link, err := urlbuilder.New(base, path...)
+	if err != nil {
+		return nil, err
+	}
+
+	return link, nil
+}
diff --git a/providers/mixmax.go b/providers/mixmax.go
index a5ee0aa0..73f53564 100644
--- a/providers/mixmax.go
+++ b/providers/mixmax.go
@@ -21,7 +21,7 @@ func init() {
 				Upsert: false,
 				Delete: false,
 			},
-			Proxy:     false,
+			Proxy:     true,
 			Read:      false,
 			Subscribe: false,
 			Write:     false,


Test patch:
diff --git a/test/marketo/connector.go b/test/marketo/connector.go
new file mode 100644
index 00000000..283dffc4
--- /dev/null
+++ b/test/marketo/connector.go
@@ -0,0 +1,52 @@
+package marketo
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"github.com/amp-labs/connectors/marketo"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/test/utils"
+	"golang.org/x/oauth2/clientcredentials"
+)
+
+func GetMarketoConnector(ctx context.Context) *marketo.Connector {
+	reader := getMarketoJSONReader()
+
+	conn, err := marketo.NewConnector(
+		marketo.WithClient(ctx, http.DefaultClient, getConfig(reader)),
+		marketo.WithWorkspace(reader.Get(credscanning.Fields.Workspace)),
+		marketo.WithModule(marketo.ModuleAssets),
+	)
+	if err != nil {
+		utils.Fail("error creating connector", "error", err)
+	}
+
+	return conn
+}
+
+func getConfig(reader *credscanning.ProviderCredentials) *clientcredentials.Config {
+	workspace := reader.Get(credscanning.Fields.Workspace)
+
+	return &clientcredentials.Config{
+		ClientID:     reader.Get(credscanning.Fields.ClientId),
+		ClientSecret: reader.Get(credscanning.Fields.ClientSecret),
+		TokenURL:     fmt.Sprintf("https://%s.mktorest.com/identity/oauth/token", workspace),
+		Scopes:       []string{},
+	}
+}
+
+func GetMarketoAccessToken() string {
+	reader := getMarketoJSONReader()
+
+	return reader.Get(credscanning.Fields.AccessToken)
+}
+
+func getMarketoJSONReader() *credscanning.ProviderCredentials {
+	filePath := credscanning.LoadPath(providers.Marketo)
+	reader := utils.MustCreateProvCredJSON(filePath, true, true)
+
+	return reader
+}
diff --git a/test/marketo/read/read.go b/test/marketo/read/read.go
new file mode 100644
index 00000000..22388135
--- /dev/null
+++ b/test/marketo/read/read.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+	"time"
+
+	"github.com/amp-labs/connectors/common"
+	mk "github.com/amp-labs/connectors/test/marketo"
+)
+
+func main() {
+	os.Exit(MainFn())
+}
+
+func MainFn() int {
+	err := testRead(context.Background())
+	if err != nil {
+		return 1
+	}
+
+	return 0
+}
+
+func testRead(ctx context.Context) error {
+	conn := mk.GetMarketoConnector(ctx)
+
+	params := common.ReadParams{
+		ObjectName: "channels",
+		Fields:     []string{"applicableProgramType", "id", "name"},
+		Since:      time.Now().Add(-720 * time.Hour),
+	}
+
+	res, err := conn.Read(ctx, params)
+	if err != nil {
+		log.Fatal(err.Error())
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
diff --git a/test/marketo/read/sample_response/channels.json b/test/marketo/read/sample_response/channels.json
new file mode 100644
index 00000000..c63dc752
--- /dev/null
+++ b/test/marketo/read/sample_response/channels.json
@@ -0,0 +1,666 @@
+{
+  "rows": 15,
+  "data": [
+    {
+      "fields": {
+        "applicableprogramtype": "program",
+        "id": 3,
+        "name": "Online Advertising"
+      },
+      "raw": {
+        "applicableProgramType": "program",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 3,
+        "name": "Online Advertising",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Filled-out Form",
+            "step": 10,
+            "success": false,
+            "type": "Filled-out Form"
+          },
+          {
+            "hidden": false,
+            "name": "Influenced",
+            "step": 20,
+            "success": true,
+            "type": "Influenced"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "webinar",
+        "id": 1,
+        "name": "Webinar"
+      },
+      "raw": {
+        "applicableProgramType": "webinar",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 1,
+        "name": "Webinar",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Invited",
+            "step": 10,
+            "success": false,
+            "type": "Invited"
+          },
+          {
+            "hidden": false,
+            "name": "Registration Error",
+            "step": 20,
+            "success": false,
+            "type": "Registration Error"
+          },
+          {
+            "hidden": false,
+            "name": "Waitlisted",
+            "step": 20,
+            "success": false,
+            "type": "Waitlisted"
+          },
+          {
+            "hidden": false,
+            "name": "Registered",
+            "step": 20,
+            "success": false,
+            "type": "Registered"
+          },
+          {
+            "hidden": false,
+            "name": "No Show",
+            "step": 30,
+            "success": false,
+            "type": "No Show"
+          },
+          {
+            "hidden": false,
+            "name": "Attended",
+            "step": 40,
+            "success": true,
+            "type": "Attended"
+          },
+          {
+            "hidden": false,
+            "name": "Attended On-demand",
+            "step": 50,
+            "success": true,
+            "type": "Attended On-demand"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "webinar",
+        "id": 2,
+        "name": "Webinar (Asynchronous)"
+      },
+      "raw": {
+        "applicableProgramType": "webinar",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 2,
+        "name": "Webinar (Asynchronous)",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Invited",
+            "step": 10,
+            "success": false,
+            "type": "Invited"
+          },
+          {
+            "hidden": false,
+            "name": "Registration Error",
+            "step": 20,
+            "success": false,
+            "type": "Registration Error"
+          },
+          {
+            "hidden": false,
+            "name": "Registering",
+            "step": 20,
+            "success": false,
+            "type": "Registering"
+          },
+          {
+            "hidden": false,
+            "name": "Waitlisted",
+            "step": 20,
+            "success": false,
+            "type": "Waitlisted"
+          },
+          {
+            "hidden": false,
+            "name": "Registered",
+            "step": 20,
+            "success": false,
+            "type": "Registered"
+          },
+          {
+            "hidden": false,
+            "name": "No Show",
+            "step": 30,
+            "success": false,
+            "type": "No Show"
+          },
+          {
+            "hidden": false,
+            "name": "Attended",
+            "step": 40,
+            "success": true,
+            "type": "Attended"
+          },
+          {
+            "hidden": false,
+            "name": "Attended On-demand",
+            "step": 50,
+            "success": true,
+            "type": "Attended On-demand"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "event",
+        "id": 4,
+        "name": "Tradeshow"
+      },
+      "raw": {
+        "applicableProgramType": "event",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 4,
+        "name": "Tradeshow",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Invited",
+            "step": 10,
+            "success": false,
+            "type": "Invited"
+          },
+          {
+            "hidden": false,
+            "name": "Waitlisted",
+            "step": 20,
+            "success": false,
+            "type": "Waitlisted"
+          },
+          {
+            "hidden": false,
+            "name": "Registered",
+            "step": 20,
+            "success": false,
+            "type": "Registered"
+          },
+          {
+            "hidden": false,
+            "name": "Visited Booth",
+            "step": 30,
+            "success": false,
+            "type": "Visited"
+          },
+          {
+            "hidden": false,
+            "name": "Influenced",
+            "step": 40,
+            "success": true,
+            "type": "Influenced"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "event",
+        "id": 5,
+        "name": "Live Event"
+      },
+      "raw": {
+        "applicableProgramType": "event",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 5,
+        "name": "Live Event",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Invited",
+            "step": 10,
+            "success": false,
+            "type": "Invited"
+          },
+          {
+            "hidden": false,
+            "name": "Waitlisted",
+            "step": 20,
+            "success": false,
+            "type": "Waitlisted"
+          },
+          {
+            "hidden": false,
+            "name": "Registered",
+            "step": 20,
+            "success": false,
+            "type": "Registered"
+          },
+          {
+            "hidden": false,
+            "name": "No Show",
+            "step": 30,
+            "success": false,
+            "type": "No Show"
+          },
+          {
+            "hidden": false,
+            "name": "Attended",
+            "step": 40,
+            "success": true,
+            "type": "Attended"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "program",
+        "id": 6,
+        "name": "List Import"
+      },
+      "raw": {
+        "applicableProgramType": "program",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 6,
+        "name": "List Import",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "On List",
+            "step": 10,
+            "success": false,
+            "type": "On List"
+          },
+          {
+            "hidden": false,
+            "name": "Influenced",
+            "step": 20,
+            "success": true,
+            "type": "Influenced"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "program",
+        "id": 7,
+        "name": "Web Content"
+      },
+      "raw": {
+        "applicableProgramType": "program",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 7,
+        "name": "Web Content",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Filled-out Form",
+            "step": 10,
+            "success": false,
+            "type": "Filled-out Form"
+          },
+          {
+            "hidden": false,
+            "name": "Influenced",
+            "step": 20,
+            "success": true,
+            "type": "Influenced"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "nurture",
+        "id": 8,
+        "name": "Nurture"
+      },
+      "raw": {
+        "applicableProgramType": "nurture",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 8,
+        "name": "Nurture",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Member",
+            "step": 10,
+            "success": false,
+            "type": "Member"
+          },
+          {
+            "hidden": false,
+            "name": "Influenced",
+            "step": 20,
+            "success": true,
+            "type": "Influenced"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "email_batch",
+        "id": 9,
+        "name": "Email Send"
+      },
+      "raw": {
+        "applicableProgramType": "email_batch",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 9,
+        "name": "Email Send",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Member",
+            "step": 10,
+            "success": false,
+            "type": "Member"
+          },
+          {
+            "hidden": false,
+            "name": "Influenced",
+            "step": 20,
+            "success": true,
+            "type": "Influenced"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "email_batch",
+        "id": 10,
+        "name": "Newsletter"
+      },
+      "raw": {
+        "applicableProgramType": "email_batch",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 10,
+        "name": "Newsletter",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Member",
+            "step": 10,
+            "success": false,
+            "type": "Member"
+          },
+          {
+            "hidden": false,
+            "name": "Influenced",
+            "step": 20,
+            "success": true,
+            "type": "Influenced"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "program",
+        "id": 12,
+        "name": "Operational"
+      },
+      "raw": {
+        "applicableProgramType": "program",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 12,
+        "name": "Operational",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Member",
+            "step": 10,
+            "success": false,
+            "type": "Member"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "program",
+        "id": 11,
+        "name": "Web Request"
+      },
+      "raw": {
+        "applicableProgramType": "program",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 11,
+        "name": "Web Request",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Filled-out Form",
+            "step": 10,
+            "success": true,
+            "type": "Filled-out Form"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "in_app",
+        "id": 13,
+        "name": "Mobile In-App"
+      },
+      "raw": {
+        "applicableProgramType": "in_app",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 13,
+        "name": "Mobile In-App",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Viewed",
+            "step": 10,
+            "success": false,
+            "type": "Viewed"
+          },
+          {
+            "hidden": false,
+            "name": "Engaged",
+            "step": 20,
+            "success": false,
+            "type": "Engaged"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "web",
+        "id": 14,
+        "name": "Web Personalization"
+      },
+      "raw": {
+        "applicableProgramType": "web",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 14,
+        "name": "Web Personalization",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Viewed",
+            "step": 10,
+            "success": false,
+            "type": "Viewed"
+          },
+          {
+            "hidden": false,
+            "name": "Engaged",
+            "step": 20,
+            "success": true,
+            "type": "Engaged"
+          },
+          {
+            "hidden": false,
+            "name": "Converted",
+            "step": 30,
+            "success": true,
+            "type": "Converted"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    },
+    {
+      "fields": {
+        "applicableprogramtype": "program",
+        "id": 15,
+        "name": "Chat"
+      },
+      "raw": {
+        "applicableProgramType": "program",
+        "createdAt": "2024-03-10T09:24:11Z+0000",
+        "id": 15,
+        "name": "Chat",
+        "progressionStatuses": [
+          {
+            "hidden": false,
+            "name": "Not in Program",
+            "step": 0,
+            "success": false,
+            "type": "Not in Program"
+          },
+          {
+            "hidden": false,
+            "name": "Member",
+            "step": 10,
+            "success": false,
+            "type": "Member"
+          },
+          {
+            "hidden": false,
+            "name": "Engaged",
+            "step": 20,
+            "success": true,
+            "type": "Engaged"
+          }
+        ],
+        "updatedAt": "2024-03-10T09:24:11Z+0000"
+      }
+    }
+  ],
+  "done": true
+}