Repository: amp-labs/connectors
Pull request #1371 (2025-02-17 10:38:38)

[CON-1062] feat: Add Freshdesk Read Connector
Tests
<img width="1224" alt="Screenshot 2025-02-17 at 13 26 10" src="https://github.com/user-attachments/assets/c4e428b7-798b-4aae-af37-1017ebc1ecb4" />



Patch:
diff --git a/providers/freshdesk/metadata.go b/providers/freshdesk/metadata.go
index 34af74b2..b3a12bc4 100644
--- a/providers/freshdesk/metadata.go
+++ b/providers/freshdesk/metadata.go
@@ -32,8 +32,7 @@ func (conn *Connector) ListObjectMetadata(ctx context.Context,
 			return nil, err
 		}
 
-		url.WithQueryParam("page", "1")
-		url.WithQueryParam("per_page", "1")
+		url.WithQueryParam(pageKey, metadataPage)
 
 		response, err := conn.Client.Get(ctx, url.String())
 		if err != nil {
diff --git a/providers/freshdesk/parse.go b/providers/freshdesk/parse.go
new file mode 100644
index 00000000..be469bea
--- /dev/null
+++ b/providers/freshdesk/parse.go
@@ -0,0 +1,37 @@
+package freshdesk
+
+import (
+	"strings"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/spyzhov/ajson"
+)
+
+func nextRecordsURL(link string) common.NextPageFunc {
+	return func(n *ajson.Node) (string, error) {
+		// if link is available, this indicates there's a next page url else there isn't.
+		// link format `<https://ampersand.freshdesk.com/api/v2/contacts?per_page=1&page=2>; rel="next"`
+		return ParseNexPageLinkHeader(link), nil
+	}
+}
+
+// ParseLinkHeader extracts the next page URL from the Link Header response.
+func ParseNexPageLinkHeader(linkHeader string) string {
+	var url string
+
+	if linkHeader == "" {
+		return "" // this indicates we're done.
+	}
+
+	links := strings.Split(linkHeader, ",")
+	// `<https://ampersand.freshdesk.com/api/v2/contacts?per_page=1&page=2>; rel="next"
+	for _, link := range links {
+		if strings.Contains(link, `rel="next"`) {
+			urls := strings.Split(link, ";")
+			url = strings.TrimPrefix(urls[0], "<")
+			url = strings.TrimRight(url, ">")
+		}
+	}
+
+	return url
+}
diff --git a/providers/freshdesk/read.go b/providers/freshdesk/read.go
new file mode 100644
index 00000000..5e4bd140
--- /dev/null
+++ b/providers/freshdesk/read.go
@@ -0,0 +1,34 @@
+package freshdesk
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+func (conn *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
+	if err := config.ValidateParams(true); err != nil {
+		return nil, err
+	}
+
+	url, err := conn.buildReadURL(config)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := conn.Client.Get(ctx, url.String())
+	if err != nil {
+		return nil, err
+	}
+
+	// we're using the link response header to construct the next page url.
+	linkHeader := resp.Headers.Get("link")
+
+	return common.ParseResult(
+		resp,
+		common.GetRecordsUnderJSONPath(""), // we're reading the current node.
+		nextRecordsURL(linkHeader),
+		common.GetMarshaledData,
+		config.Fields,
+	)
+}
diff --git a/providers/freshdesk/url.go b/providers/freshdesk/url.go
new file mode 100644
index 00000000..ce7f3dba
--- /dev/null
+++ b/providers/freshdesk/url.go
@@ -0,0 +1,38 @@
+package freshdesk
+
+import (
+	"time"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+)
+
+const (
+	pageKey        = "per_page"
+	readPageCount  = "100"
+	metadataPage   = "1"
+	sinceFilterKey = "updated_since"
+)
+
+func (conn *Connector) buildReadURL(config common.ReadParams) (*urlbuilder.URL, error) {
+	if len(config.NextPage) > 0 {
+		return urlbuilder.New(string(config.NextPage))
+	}
+
+	if !objectReadSupported(config.ObjectName) {
+		return nil, common.ErrObjectNotSupported
+	}
+
+	url, err := conn.getAPIURL(config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	url.WithQueryParam(pageKey, readPageCount)
+
+	if !config.Since.IsZero() {
+		url.WithQueryParam(sinceFilterKey, config.Since.Format(time.RFC3339))
+	}
+
+	return url, nil
+}


Test patch:
diff --git a/providers/freshdesk/read_test.go b/providers/freshdesk/read_test.go
new file mode 100644
index 00000000..f7064e93
--- /dev/null
+++ b/providers/freshdesk/read_test.go
@@ -0,0 +1,100 @@
+package freshdesk
+
+import (
+	"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, "empty.json")
+	mailboxes := testutils.DataFromFile(t, "mailboxes.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: "meme", Fields: datautils.NewStringSet("name")},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusNotFound, nil),
+			}.Server(),
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
+		},
+		{
+			Name:  "Zero records response",
+			Input: common.ReadParams{ObjectName: "companies", Fields: connectors.Fields("id")},
+			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 Mailboxes",
+			Input: common.ReadParams{
+				ObjectName: "mailboxes",
+				Fields:     connectors.Fields("id", "name"),
+			},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusOK, mailboxes),
+			}.Server(),
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"id":   float64(1),
+						"name": "Test Account",
+					},
+					Raw: map[string]any{
+						"id":                  float64(1),
+						"name":                "Test Account",
+						"support_email":       "testemail@gmail.com",
+						"group_id":            float64(1),
+						"default_reply_email": true,
+						"active":              true,
+						"mailbox_type":        "freshdesk_mailbox",
+						"created_at":          "2019-08-20T05:55:38Z",
+						"updated_at":          "2019-08-20T05:55:38Z",
+						"product_id":          float64(2),
+						"freshdesk_mailbox": map[string]any{
+							"forward_email": "gmailcomtestemail@freshdesk.com",
+						},
+					},
+				}},
+				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/freshdesk/test/mailboxes.json b/providers/freshdesk/test/mailboxes.json
index 781aa417..b63da5d7 100644
--- a/providers/freshdesk/test/mailboxes.json
+++ b/providers/freshdesk/test/mailboxes.json
@@ -13,51 +13,5 @@
     "freshdesk_mailbox": {
       "forward_email": "gmailcomtestemail@freshdesk.com"
     }
-  },
-  {
-    "id": 4,
-    "name": "mailbox",
-    "support_email": "superman@gmail.com",
-    "group_id": 6,
-    "default_reply_email": true,
-    "active": false,
-    "mailbox_type": "custom_mailbox",
-    "created_at": "2019-09-04T08:17:26Z",
-    "updated_at": "2019-09-18T11:42:32Z",
-    "product_id": 1,
-    "custom_mailbox": {
-      "access_type": "both",
-      "incoming": {
-        "mail_server": "imap.gmail.com",
-        "port": 993,
-        "use_ssl": true,
-        "delete_from_server": false,
-        "authentication": "plain",
-        "user_name": "superman@gmail.com",
-        "failure_code": "null"
-      },
-      "outgoing": {
-        "mail_server": "smtp.gmail.com",
-        "port": 587,
-        "use_ssl": true,
-        "authentication": "plain",
-        "user_name": "superman@gmail.com"
-      }
-    }
-  },
-  {
-    "id": 5,
-    "name": "Test 2",
-    "support_email": "batman@gmail.com",
-    "group_id": null,
-    "default_reply_email": true,
-    "active": true,
-    "mailbox_type": "freshdesk_mailbox",
-    "created_at": "2019-08-21T05:14:48Z",
-    "updated_at": "2019-09-05T07:19:30Z",
-    "product_id": 2,
-    "freshdesk_mailbox": {
-      "forward_email": "gmailcombatman@gmail.com"
-    }
   }
 ]
diff --git a/test/freshdesk/read/main.go b/test/freshdesk/read/main.go
new file mode 100644
index 00000000..1ac671d4
--- /dev/null
+++ b/test/freshdesk/read/main.go
@@ -0,0 +1,69 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	fd "github.com/amp-labs/connectors/providers/freshdesk"
+	"github.com/amp-labs/connectors/test/freshdesk"
+	"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 := freshdesk.GetFreshdeskConnector(ctx)
+
+	if err := testRead(ctx, conn, "tickets", []string{"id", "name"}); err != nil {
+		slog.Error(err.Error())
+	}
+
+	if err := testRead(ctx, conn, "contacts", []string{"name", "id", "mobile", "job_title"}); err != nil {
+		slog.Error(err.Error())
+	}
+
+	if err := testRead(ctx, conn, "companies", []string{"id", "name", "note"}); err != nil {
+		slog.Error(err.Error())
+	}
+}
+
+func testRead(ctx context.Context, conn *fd.Connector, objectName string, fields []string) error {
+	params := common.ReadParams{
+		ObjectName: objectName,
+		Fields:     connectors.Fields(fields...),
+		Since:      time.Now().Add(-50 * time.Hour),
+		NextPage:   "",
+	}
+
+	res, err := conn.Read(ctx, params)
+	if err != nil {
+		return fmt.Errorf("failed to read %s: %w", objectName, err)
+	}
+
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	if _, err := os.Stdout.Write(jsonStr); err != nil {
+		return fmt.Errorf("error writing to stdout: %w", err)
+	}
+	if _, err := os.Stdout.WriteString("\n"); err != nil {
+		return fmt.Errorf("error writing to stdout: %w", err)
+	}
+
+	return nil
+}