Repository: amp-labs/connectors
Pull request #1496 (2025-03-19 14:32:25)

[CON-1468] feat: Add Drift Metadata Connector
Tests:
<img width="1227" alt="Screenshot 2025-03-19 at 17 29 36" src="https://github.com/user-attachments/assets/fc33c7ee-ba27-4610-96a1-0ae8c661df01" />


Cobalt0s: > Pulling in @Cobalt0s - how do you suggest we gather metadata for this API? Trying to read a single object is okay, but can have it's downsides - any suggestions?

The API offers several models: [Drift API Documentation](https://devdocs.drift.com/docs/platform-apis).

We could consider scraping for this task. I have a brief summary [here](https://ampersand.slab.com/posts/sources-for-object-metadata-sl2kdcpi#h2zir-scraper).


# Approach

Given a list of **model documentation URLs** stored in `index.json`, the script would focus on locating the table that contains the metadata. For example, for the [User model](https://devdocs.drift.com/docs/user-model), you could run document.querySelectorAll(".rdmd-table tr") to get the rows. The Capsule script would be helpful since it works directly with tables. See the [code reference](https://github.com/amp-labs/connectors/blob/9c8000ea539cb9597d3d01138ac136f2faeca712/scripts/scraper/capsule/metadata/main.go#L152).

Note: `index.json` could be created **manually**. It will include the URLs that have **tables** which you want to turn into object metadata. Capsule and Salesloft used query selectors to create an index to act uppon.

In Capsule, I was able to extract **field values** using regex, and the same can be done in Drift:
![image](https://github.com/user-attachments/assets/52e03cc8-ec7c-4e97-9b5c-732dc844b487)
Both the Availability and Role fields can be stored as singleSelect types.
Capsule `schema.json`:
![image](https://github.com/user-attachments/assets/0a29e85f-bbc5-416a-b86a-6d1d4f34fd46)


## Pros
* Query selectors are straightforward to locate model's **table**.
* This approach offers more object metadata. V2 instead of V1.

## Cons
* Extracting the data may require some experimentation with string regex and formats. ([Capsule example](https://github.com/amp-labs/connectors/blob/9c8000ea539cb9597d3d01138ac136f2faeca712/scripts/scraper/capsule/metadata/main.go#L206))
* Using scraping for me is always a disadvantage, because in future the script may break.
Jkarage: Looking into this, seems like a better option.

Patch:
diff --git a/providers/drift/connector.go b/providers/drift/connector.go
new file mode 100644
index 00000000..912b9a7b
--- /dev/null
+++ b/providers/drift/connector.go
@@ -0,0 +1,47 @@
+package drift
+
+import (
+	_ "embed"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/tools/fileconv"
+	"github.com/amp-labs/connectors/tools/scrapper"
+)
+
+// nolint:gochecknoglobals
+var (
+	//go:embed schemas.json
+	schemaContent []byte
+
+	fileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1](
+		schemaContent, fileconv.NewSiblingFileLocator())
+
+	schemas = fileManager.MustLoadSchemas()
+)
+
+type Connector struct {
+	// Basic connector
+	*components.Connector
+
+	// Require authenticated client
+	common.RequireAuthenticatedClient
+
+	// Supported operations
+	components.SchemaProvider
+}
+
+func NewConnector(params common.Parameters) (*Connector, error) {
+	return components.Initialize(providers.Drift, params, constructor)
+}
+
+func constructor(base *components.Connector) (*Connector, error) {
+	connector := &Connector{Connector: base}
+
+	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), schemas)
+
+	return connector, nil
+}
diff --git a/providers/drift/schemas.json b/providers/drift/schemas.json
new file mode 100644
index 00000000..9580500d
--- /dev/null
+++ b/providers/drift/schemas.json
@@ -0,0 +1,162 @@
+{
+  "modules": {
+    "root": {
+      "id": "root",
+      "path": "",
+      "objects": {
+        "users/list": {
+          "displayName": "Users",
+          "path": "/users/list",
+          "docs": "https://devdocs.drift.com/docs/listing-users",
+          "fields": {
+            "id": "id",
+            "orgId": "orgId",
+            "name": "name",
+            "alias": "alias",
+            "email": "email",
+            "phone": "phone",
+            "locale": "locale",
+            "availability": "availability",
+            "role": "role",
+            "timeZone": "timeZone",
+            "avatarUrl": "avatarUrl",
+            "verified": "verified",
+            "bot": "bot",
+            "createdAt": "createdAt",
+            "updatedAt": "updatedAt"
+          }
+        },
+        "conversations/list": {
+          "displayName": "Conversations List",
+          "path": "/conversations/list",
+          "docs": "https://devdocs.drift.com/docs/list-conversations",
+          "fields": {
+            "id": "id",
+            "participants": "participants",
+            "status": "status",
+            "contactId": "contactId",
+            "inboxId": "inboxId",
+            "createdAt": "createdAt",
+            "updatedAt": "updatedAt",
+            "relatedPlaybookId": "relatedPlaybookId",
+            "conversationTags": "conversationTags"
+          }
+        },
+        "teams/org": {
+          "displayName": "Teams in Org",
+          "path": "/teams/org",
+          "docs": "https://devdocs.drift.com/docs/listing-teams-org",
+          "fields": {
+            "id": "id",
+            "orgId": "orgId",
+            "workspaceId": "workspaceId",
+            "name": "name",
+            "updatedAt": "updatedAt",
+            "members": "members",
+            "owner": "owner",
+            "status": "status",
+            "main": "main",
+            "autoOffline": "autoOffline",
+            "teamAvailabilityMode": "teamAvailabilityMode",
+            "responseTimerEnabled": "responseTimerEnabled"
+          }
+        },
+        "users/meetings/org": {
+          "displayName": "User Meetings",
+          "path": "/users/meetings/org",
+          "docs": "https://devdocs.drift.com/docs/get-booked-meetings",
+          "fields": {
+            "conversationId": "conversationId",
+            "agentId": "agentId",
+            "orgId": "orgId",
+            "status": "status",
+            "meetingSource": "meetingSource",
+            "schedulerId": "schedulerId",
+            "eventId": "eventId",
+            "slug": "slug",
+            "slotStart": "slotStart",
+            "slotEnd": "slotEnd",
+            "updatedAt": "updatedAt",
+            "scheduledAt": "scheduledAt",
+            "meetingType": "meetingType",
+            "meetingSlug": "meetingSlug",
+            "isOrgSlug": "isOrgSlug",
+            "endUserTimeZone": "endUserTimeZone",
+            "conferenceType": "conferenceType",
+            "conferenceDetails": "conferenceDetails",
+            "isRecurring": "isRecurring",
+            "isPrivate": "isPrivate"
+          }
+        },
+        "playbooks/list": {
+          "displayName": "Playbooks Lists",
+          "path": "/playbooks/list",
+          "docs": "https://devdocs.drift.com/docs/get-playbooks",
+          "fields": {
+            "id": "id",
+            "name": "name",
+            "orgId": "orgId",
+            "meta": "meta",
+            "createdAt": "createdAt",
+            "updatedAt": "updatedAt",
+            "createdAuthorId": "createdAuthorId",
+            "updatedAuthorId": "updatedAuthorId",
+            "interactionId": "interactionId",
+            "reportType": "reportType",
+            "goals": "goals"
+          }
+        },
+        "playbooks/clp": {
+          "displayName": "Playbooks Clp",
+          "path": "/playbooks/clp",
+          "docs": "https://devdocs.drift.com/docs/retrieve-conversational-landing-pages",
+          "fields": {
+            "playbookId": "playbookId",
+            "playbookName": "playbookName",
+            "landingPageUrl": "landingPageUrl"
+          }
+        },
+        "conversations/stats": {
+          "displayName": "Conversations Stats",
+          "path": "/conversations/stats",
+          "docs": "https://devdocs.drift.com/docs/retrieve-conversational-landing-pages",
+          "fields": {
+            "conversationCount": "conversationCount"
+          }
+        },
+        "scim/Users": {
+          "displayName": "Scim Users",
+          "path": "/scim/Users",
+          "docs": "https://devdocs.drift.com/docs/searching",
+          "fields": {
+            "schemas": "schemas",
+            "id": "id",
+            "externalId": "externalId",
+            "meta": "meta",
+            "userName": "userName",
+            "name": "name",
+            "displayName": "displayName",
+            "nickName": "nickName",
+            "profileUrl": "profileUrl",
+            "title": "title",
+            "userType": "userType",
+            "preferredLanguage": "preferredLanguage",
+            "locale": "locale",
+            "timezone": "timezone",
+            "active": "active",
+            "password": "password",
+            "emails": "emails",
+            "phoneNumbers": "phoneNumbers",
+            "ims": "ims",
+            "photos": "photos",
+            "addresses": "addresses",
+            "groups": "groups",
+            "entitlements": "entitlements",
+            "roles": "roles",
+            "x509Certificates": "x509Certificates"
+          }
+        }
+      }
+    }
+  }
+}


Test patch:
diff --git a/test/drift/connector.go b/test/drift/connector.go
new file mode 100644
index 00000000..43848765
--- /dev/null
+++ b/test/drift/connector.go
@@ -0,0 +1,51 @@
+package drift
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/drift"
+	"github.com/amp-labs/connectors/test/utils"
+	"golang.org/x/oauth2"
+)
+
+func GetConnector(ctx context.Context) *drift.Connector {
+	filePath := credscanning.LoadPath(providers.Drift)
+	reader := utils.MustCreateProvCredJSON(filePath, true, false)
+
+	client, err := common.NewOAuthHTTPClient(ctx,
+		common.WithOAuthClient(http.DefaultClient),
+		common.WithOAuthConfig(getConfig(reader)),
+		common.WithOAuthToken(reader.GetOauthToken()),
+	)
+	if err != nil {
+		utils.Fail(err.Error())
+	}
+
+	conn, err := drift.NewConnector(common.Parameters{
+		AuthenticatedClient: client,
+	})
+	if err != nil {
+		utils.Fail("error creating connector", "error", err)
+	}
+
+	return conn
+}
+
+func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config {
+	cfg := oauth2.Config{
+		ClientID:     reader.Get(credscanning.Fields.ClientId),
+		ClientSecret: reader.Get(credscanning.Fields.ClientSecret),
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  "https://dev.drift.com/authorize",
+			TokenURL: "https://driftapi.com/oauth2/token",
+
+			AuthStyle: oauth2.AuthStyleAutoDetect,
+		},
+	}
+
+	return &cfg
+}
diff --git a/test/drift/metadata/main.go b/test/drift/metadata/main.go
new file mode 100644
index 00000000..583e2188
--- /dev/null
+++ b/test/drift/metadata/main.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/amp-labs/connectors/test/drift"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	if err := run(); err != nil {
+		utils.Fail(err.Error())
+	}
+}
+
+func run() error {
+	ctx := context.Background()
+	connector := drift.GetConnector(ctx)
+
+	m, err := connector.ListObjectMetadata(ctx, []string{"users/list", "conversations/stats", "conversations/list", "teams/org"})
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	fmt.Println("Results: ", m.Result)
+	fmt.Println("Errors: ", m.Errors)
+
+	return nil
+}