Repository: amp-labs/connectors
Pull request #1302 (2025-01-24 09:49:02)

[CON-999] feat: Add ChiliPiper Connector
## Catalog variables
None. We could choose to use it though.

## Testing
### GET
<img width="1150" alt="Screenshot 2025-01-24 at 12 20 03" src="https://github.com/user-attachments/assets/a999a89c-8dd5-44d3-91e8-328af48589de" />


### POST
<img width="1157" alt="Screenshot 2025-01-24 at 12 24 59" src="https://github.com/user-attachments/assets/1cdd8794-4541-424d-a00b-5620523dcd2f" />

### DELETE
<img width="1153" alt="Screenshot 2025-01-24 at 12 39 15" src="https://github.com/user-attachments/assets/b2c4db90-0ac0-43c5-b607-f28def0757cd" />


## Pagination
Pagination uses the query param `page` and `pageSize`.
<img width="1158" alt="Screenshot 2025-01-24 at 12 29 59" src="https://github.com/user-attachments/assets/fb2a5938-7461-416c-8618-b557e6a166fc" />

You'd have to calculate how much you should go as they give how many records are available.
<img width="1163" alt="Screenshot 2025-01-24 at 12 47 10" src="https://github.com/user-attachments/assets/d26d5638-7c02-46c8-9130-69eb40d4b82e" />

# Logo & Icons
Used the same for logo & icon. It's an svg.
<img width="1271" alt="Screenshot 2025-01-24 at 11 16 20" src="https://github.com/user-attachments/assets/a3987514-2724-44e1-86fa-e74515926961" />



Patch:
diff --git a/providers/chilipiper.go b/providers/chilipiper.go
new file mode 100644
index 00000000..d9629a86
--- /dev/null
+++ b/providers/chilipiper.go
@@ -0,0 +1,41 @@
+package providers
+
+const ChiliPiper Provider = "chilipiper"
+
+func init() {
+	SetInfo(ChiliPiper, ProviderInfo{
+		DisplayName: "Chili Piper",
+		AuthType:    ApiKey,
+		BaseURL:     "https://fire.chilipiper.com",
+		ApiKeyOpts: &ApiKeyOpts{
+			AttachmentType: Header,
+			Header: &ApiKeyOptsHeader{
+				Name:        "Authorization",
+				ValuePrefix: "Bearer ",
+			},
+		},
+		//nolint:lll
+		Media: &Media{
+			DarkMode: &MediaTypeDarkMode{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1737706607/media/chilipiper.com_1737706605.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1737706607/media/chilipiper.com_1737706605.svg",
+			},
+			Regular: &MediaTypeRegular{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1737706607/media/chilipiper.com_1737706605.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1737706607/media/chilipiper.com_1737706605.svg",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	})
+}
diff --git a/providers/hubspot/schema.go b/providers/hubspot/schema.go
new file mode 100644
index 00000000..67d49aa4
--- /dev/null
+++ b/providers/hubspot/schema.go
@@ -0,0 +1,29 @@
+package hubspot
+
+// https://developers.hubspot.com/docs/guides/api/crm/understanding-the-crm#object-type-ids
+//
+//nolint:gochecknoglobals
+var KnownObjectTypes = map[string]string{
+	"0-2":   "companies",
+	"0-1":   "contacts",
+	"0-3":   "deals",
+	"0-5":   "tickets",
+	"0-421": "appointments",
+	"0-48":  "calls",
+	"0-18":  "communications",
+	"0-410": "courses",
+	"0-49":  "emails",
+	"0-136": "leads",
+	"0-8":   "line_items",
+	"0-420": "listings",
+	"0-54":  "marketing_events",
+	"0-47":  "meetings",
+	"0-46":  "notes",
+	"0-116": "postal_mail",
+	"0-7":   "products",
+	"0-14":  "quotes",
+	"0-162": "services",
+	"0-69":  "subscriptions",
+	"0-27":  "tasks",
+	"0-115": "users",
+}
diff --git a/providers/hubspot/subscriptionEvent.go b/providers/hubspot/subscriptionEvent.go
index 4b4e061d..b880bb8c 100644
--- a/providers/hubspot/subscriptionEvent.go
+++ b/providers/hubspot/subscriptionEvent.go
@@ -12,8 +12,17 @@ import (
 	"time"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/gertd/go-pluralize"
 )
 
+//nolint:lll
+/*
+Older version doc: https://developers.hubspot.com/docs/guides/api/app-management/webhooks#webhook-subscriptions
+
+Newer version doc: https://developers.hubspot.com/docs/guides/apps/public-apps/create-generic-webhook-subscriptions#general
+
+*/
+
 /*
 Note:
 SubscriptionEvent is a map[string]any as opposed to a typed struct, because the structure of the event is not known .
@@ -87,6 +96,19 @@ func (evt SubscriptionEvent) RawEventName() (string, error) {
 var errSubscriptionSupportedForObject = errors.New("subscription is not supported for the object")
 
 func (evt SubscriptionEvent) ObjectName() (string, error) {
+	objType, err := evt.ObjectTypeId()
+	if err == nil {
+		// this is newer version(generic webhook) of event case where objectTypeId is present that
+		// subscriptionType does not have reference to object name e.g) object.creation
+		// https://developers.hubspot.com/docs/guides/apps/public-apps/create-generic-webhook-subscriptions#general
+		objName, ok := KnownObjectTypes[objType]
+		if ok {
+			return pluralize.NewClient().Singular(objName), nil
+		}
+
+		return "", fmt.Errorf("%w '%s'", errSubscriptionSupportedForObject, objType)
+	}
+
 	rawEvent, err := evt.RawEventName()
 	if err != nil {
 		return "", fmt.Errorf("error getting raw event name: %w", err)
@@ -137,6 +159,12 @@ func (evt SubscriptionEvent) asMap() common.StringMap {
 	return common.StringMap(evt)
 }
 
+func (evt SubscriptionEvent) ObjectTypeId() (string, error) {
+	m := evt.asMap()
+
+	return m.GetString("objectTypeId")
+}
+
 /*
 	EXAMPLES: There is no documentation that shows data structure of subscription event.
 	Below examples were found from hubspot app settings page after login at:
diff --git a/providers/marketo/url.go b/providers/marketo/url.go
index cb2794b4..11f5ee3b 100644
--- a/providers/marketo/url.go
+++ b/providers/marketo/url.go
@@ -7,7 +7,7 @@ import (
 	"github.com/amp-labs/connectors/common/urlbuilder"
 )
 
-var restAPIPrefix = "rest" //nolint:gochecknoglobals
+const restAPIPrefix = "rest" //nolint:gochecknoglobals
 
 func (c *Connector) getURL(params common.ReadParams) (*urlbuilder.URL, error) {
 	url, err := c.getAPIURL(params.ObjectName)


Test patch:
diff --git a/providers/hubspot/subscriptionEvent_test.go b/providers/hubspot/subscriptionEvent_test.go
index 23b447ee..afe995d9 100644
--- a/providers/hubspot/subscriptionEvent_test.go
+++ b/providers/hubspot/subscriptionEvent_test.go
@@ -34,6 +34,14 @@ func TestExtractObjectNameFromSubscriptionEvent(t *testing.T) {
 
 	_, err = emptyObjectEvent.ObjectName()
 	assert.ErrorContains(t, err, "subscription is not supported for the object ''")
+
+	withObjectTypeId := SubscriptionEvent{
+		"objectTypeId": "0-1",
+	}
+
+	objectName, err = withObjectTypeId.ObjectName()
+	assert.NilError(t, err, "error should be nil")
+	assert.Equal(t, objectName, "contact", "object name should be parsed correctly")
 }
 
 //nolint:funlen
diff --git a/providers/marketo/metadata_test.go b/providers/marketo/metadata_test.go
new file mode 100644
index 00000000..ee9400f0
--- /dev/null
+++ b/providers/marketo/metadata_test.go
@@ -0,0 +1,82 @@
+package marketo
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	channelsResponse := testutils.DataFromFile(t, "channels.json")
+
+	tests := []testroutines.Metadata{
+		{
+			Name:         "At least one object name must be provided",
+			Input:        nil,
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:  "Successfully describe supported object",
+			Input: []string{"channels"},
+			Server: mockserver.Switch{
+				Setup: mockserver.ContentJSON(),
+				Cases: []mockserver.Case{{
+					If:   mockcond.PathSuffix("v1/channels.json"),
+					Then: mockserver.Response(http.StatusOK, channelsResponse),
+				}},
+			}.Server(),
+			Expected: &common.ListObjectMetadataResult{
+				Result: map[string]common.ObjectMetadata{
+					"channels": {
+						DisplayName: "channels",
+						FieldsMap: map[string]string{
+							"applicableProgramType": "applicableProgramType",
+							"createdAt":             "createdAt",
+							"id":                    "id",
+							"name":                  "name",
+							"progressionStatuses":   "progressionStatuses",
+							"updatedAt":             "updatedAt",
+						},
+					},
+				},
+				Errors: make(map[string]error),
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.ObjectMetadataConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
+
+func constructTestConnector(serverURL string) (*Connector, error) {
+	connector, err := NewConnector(
+		WithAuthenticatedClient(http.DefaultClient),
+		WithWorkspace("test-workspace"),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	// for testing we want to redirect calls to our mock server
+	connector.setBaseURL(serverURL)
+
+	return connector, nil
+}
diff --git a/providers/marketo/read_test.go b/providers/marketo/read_test.go
new file mode 100644
index 00000000..828ce11e
--- /dev/null
+++ b/providers/marketo/read_test.go
@@ -0,0 +1,100 @@
+package marketo
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestRead(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	zeroRecords := testutils.DataFromFile(t, "zero-records.json")
+	unsupportedResponse := testutils.DataFromFile(t, "not-found.json")
+	campaignsResponse := testutils.DataFromFile(t, "campaigns.json")
+
+	tests := []testroutines.Read{
+		{
+			Name:         "Object Name is required",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "At least one field is required",
+			Input:        common.ReadParams{ObjectName: "deals"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingFields},
+		},
+		{
+			Name:  "Unsupported object",
+			Input: common.ReadParams{ObjectName: "arsenal", Fields: datautils.NewStringSet("testField")},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusBadRequest, unsupportedResponse),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrCaller,
+				errors.New(string(unsupportedResponse)), //nolint:err113
+			},
+		},
+		{
+			Name:  "Zero records response",
+			Input: common.ReadParams{ObjectName: "smartcampaigns", Fields: connectors.Fields("description", "id", "name")},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusOK, zeroRecords),
+			}.Server(),
+			Expected:     &common.ReadResult{Rows: 0, Data: []common.ReadResultRow{}, Done: true},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Successfully Read Campaigns",
+			Input: common.ReadParams{
+				ObjectName: "campaign",
+				Fields:     connectors.Fields("createdAt", "id", "name"),
+			},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusOK, campaignsResponse),
+			}.Server(),
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"createdat": "2024-08-23T12:09:55Z",
+						"id":        float64(1023),
+						"name":      "Meme",
+					},
+					Raw: map[string]any{
+						"active":        false,
+						"createdAt":     "2024-08-23T12:09:55Z",
+						"id":            float64(1023),
+						"name":          "Meme",
+						"type":          "batch",
+						"updatedAt":     "2024-08-23T12:09:55Z",
+						"workspaceName": "Default",
+					},
+				}},
+				Done: true,
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.ReadConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/providers/marketo/test/campaigns.json b/providers/marketo/test/campaigns.json
new file mode 100644
index 00000000..c4c3aa20
--- /dev/null
+++ b/providers/marketo/test/campaigns.json
@@ -0,0 +1,15 @@
+{
+  "requestId": "76cc#1949367e298",
+  "result": [
+    {
+      "id": 1023,
+      "name": "Meme",
+      "type": "batch",
+      "workspaceName": "Default",
+      "createdAt": "2024-08-23T12:09:55Z",
+      "updatedAt": "2024-08-23T12:09:55Z",
+      "active": false
+    }
+  ],
+  "success": true
+}
diff --git a/providers/marketo/test/channels.json b/providers/marketo/test/channels.json
new file mode 100644
index 00000000..c60446da
--- /dev/null
+++ b/providers/marketo/test/channels.json
@@ -0,0 +1,38 @@
+{
+  "success": true,
+  "errors": [],
+  "requestId": "1167b#194927caa59",
+  "warnings": [],
+  "result": [
+    {
+      "id": 3,
+      "name": "Online Advertising",
+      "applicableProgramType": "program",
+      "progressionStatuses": [
+        {
+          "name": "Not in Program",
+          "step": 0,
+          "type": "Not in Program",
+          "hidden": false,
+          "success": false
+        },
+        {
+          "name": "Filled-out Form",
+          "step": 10,
+          "type": "Filled-out Form",
+          "hidden": false,
+          "success": false
+        },
+        {
+          "name": "Influenced",
+          "step": 20,
+          "type": "Influenced",
+          "hidden": false,
+          "success": true
+        }
+      ],
+      "createdAt": "2024-03-10T09:24:11Z+0000",
+      "updatedAt": "2024-03-10T09:24:11Z+0000"
+    }
+  ]
+}
diff --git a/providers/marketo/test/not-found.json b/providers/marketo/test/not-found.json
new file mode 100644
index 00000000..257ca217
--- /dev/null
+++ b/providers/marketo/test/not-found.json
@@ -0,0 +1,6 @@
+{
+  "requestId": "a883#19492823069",
+  "success": false,
+  "warnings": [],
+  "errors": [{ "code": "610", "message": "Requested resource not found" }]
+}
diff --git a/providers/marketo/test/write-leads.json b/providers/marketo/test/write-leads.json
new file mode 100644
index 00000000..192da7ee
--- /dev/null
+++ b/providers/marketo/test/write-leads.json
@@ -0,0 +1,5 @@
+{
+  "requestId": "b267#194942e22e1",
+  "result": [{ "id": 550, "status": "created" }],
+  "success": true
+}
diff --git a/providers/marketo/test/zero-records.json b/providers/marketo/test/zero-records.json
new file mode 100644
index 00000000..bf513b81
--- /dev/null
+++ b/providers/marketo/test/zero-records.json
@@ -0,0 +1,6 @@
+{
+  "success": true,
+  "errors": [],
+  "requestId": "a024#1949367df25",
+  "warnings": ["No assets found for the given search criteria."]
+}
diff --git a/providers/marketo/write_test.go b/providers/marketo/write_test.go
new file mode 100644
index 00000000..017c6389
--- /dev/null
+++ b/providers/marketo/write_test.go
@@ -0,0 +1,91 @@
+package marketo
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	unsupportedResponse := testutils.DataFromFile(t, "not-found.json")
+	leadCreationResponse := testutils.DataFromFile(t, "write-leads.json")
+
+	tests := []testroutines.Write{
+		{
+			Name:         "Object Name is required",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "RecordData is required",
+			Input:        common.WriteParams{ObjectName: "leads"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordData},
+		},
+
+		{
+			Name:  "Unsupported object",
+			Input: common.WriteParams{ObjectName: "lalala", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.MethodPOST(),
+				Then:  mockserver.Response(http.StatusNotFound, unsupportedResponse),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrRetryable,
+				errors.New(string(unsupportedResponse)), // nolint:goerr113
+			},
+		},
+		{
+			Name: "Successfully creation of a lead",
+			Input: common.WriteParams{ObjectName: "leads", RecordData: map[string]any{
+				"input": []map[string]any{
+					{
+						"email":     "joseph@gmail.com",
+						"firstName": "Example Lead",
+					},
+				},
+				"action":      "createOnly",
+				"lookupField": "email",
+			}},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.PathSuffix("/v1/leads.json"),
+				},
+				Then: mockserver.Response(http.StatusOK, leadCreationResponse),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "550",
+				Data: map[string]any{
+					"id":     float64(550),
+					"status": "created",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.WriteConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}