Repository: amp-labs/connectors
Pull request #1469 (2025-03-12 00:37:20)

feat(httpkit): Link header parser
# Description

* Created the `httpkit` package.
* The Capsule provider includes the next page URL in the `Link` header. This PR adds utility to parse and extract URL data.
* The implementation delegates to `github.com/deiu/linkparser` library.

Cobalt0s: * **#1469** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1469?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1469?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->

Patch:
diff --git a/go.mod b/go.mod
index 59b77a82..120e8d3f 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/PuerkitoBio/goquery v1.10.2
 	github.com/antchfx/xmlquery v1.4.3
 	github.com/brianvoe/gofakeit/v6 v6.28.0
+	github.com/deiu/linkparser v0.0.0-20170608193052-9b6849e15168
 	github.com/gertd/go-pluralize v0.2.1
 	github.com/getkin/kin-openapi v0.129.0
 	github.com/go-playground/validator v9.31.0+incompatible
diff --git a/go.sum b/go.sum
index 28c03d50..611af918 100644
--- a/go.sum
+++ b/go.sum
@@ -13,6 +13,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/deiu/linkparser v0.0.0-20170608193052-9b6849e15168 h1:faQ0lJ7RbfOyHSVkVwmWiUk/+HOA648JNBmwIkFHlxI=
+github.com/deiu/linkparser v0.0.0-20170608193052-9b6849e15168/go.mod h1:EPdXetNGTVpWsQ9wn8LQzqNByQOjMeFhYEvNOZNGtwg=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
diff --git a/internal/httpkit/response.go b/internal/httpkit/response.go
new file mode 100644
index 00000000..4f1a18dc
--- /dev/null
+++ b/internal/httpkit/response.go
@@ -0,0 +1,19 @@
+// nolint:lll
+package httpkit
+
+import (
+	"github.com/amp-labs/connectors/common"
+	lh "github.com/deiu/linkparser"
+)
+
+// HeaderLink extracts and parses Link Header of HTTP response.
+//
+// Example: given the header below we want to return one of the URLs based on the relationship name.
+// <https://api.capsulecrm.com/api/v2/parties?page=3&perPage=1>; rel="next", <https://api.capsulecrm.com/api/v2/parties?page=1&perPage=1>; rel="prev"
+//
+// The implementation is delegated to `linkparser` library.
+func HeaderLink(resp *common.JSONHTTPResponse, relationshipName string) string {
+	link := resp.Headers.Get("Link")
+
+	return lh.ParseHeader(link)[relationshipName]["href"]
+}
diff --git a/providers/ashby/connector.go b/providers/ashby/connector.go
index a0239e91..03657178 100644
--- a/providers/ashby/connector.go
+++ b/providers/ashby/connector.go
@@ -3,7 +3,10 @@ package ashby
 import (
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/components/operations"
+	"github.com/amp-labs/connectors/internal/components/reader"
 	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/internal/components/writer"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/providers/ashby/metadata"
 )
@@ -17,6 +20,8 @@ type Connector struct {
 
 	// supported operations
 	components.SchemaProvider
+	components.Reader
+	components.Writer
 }
 
 func NewConnector(params common.Parameters) (*Connector, error) {
@@ -31,5 +36,32 @@ func constructor(base *components.Connector) (*Connector, error) {
 	// Set the metadata provider for the connector
 	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), metadata.Schemas)
 
+	registry, err := components.NewEndpointRegistry(supportedOperations())
+	if err != nil {
+		return nil, err
+	}
+
+	connector.Reader = reader.NewHTTPReader(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.ReadHandlers{
+			BuildRequest:  connector.buildReadRequest,
+			ParseResponse: connector.parseReadResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
+	connector.Writer = writer.NewHTTPWriter(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.WriteHandlers{
+			BuildRequest:  connector.buildWriteRequest,
+			ParseResponse: connector.parseWriteResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
 	return connector, nil
 }
diff --git a/providers/ashby/handlers.go b/providers/ashby/handlers.go
new file mode 100644
index 00000000..e84529b3
--- /dev/null
+++ b/providers/ashby/handlers.go
@@ -0,0 +1,160 @@
+package ashby
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/amp-labs/connectors/providers/ashby/metadata"
+	"github.com/spyzhov/ajson"
+)
+
+const (
+	pageSizeKey = "limit"
+	pageSize    = "100"
+	pageKey     = "cursor"
+	sinceKey    = "createdAfter"
+)
+
+func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadParams) (*http.Request, error) {
+	path, err := metadata.Schemas.LookupURLPath(c.Module(), params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	url, err := urlbuilder.New(c.ProviderInfo().BaseURL, path)
+	if err != nil {
+		return nil, err
+	}
+
+	body := buildRequestbody(params)
+
+	jsonData, err := json.Marshal(body)
+	if err != nil {
+		return nil, err
+	}
+
+	return http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewReader(jsonData))
+}
+
+func buildRequestbody(params common.ReadParams) map[string]any {
+	body := make(map[string]any)
+
+	if supportSince.Has(params.ObjectName) && !params.Since.IsZero() {
+		body[sinceKey] = params.Since.UnixMilli()
+	}
+
+	if supportPagination.Has(params.ObjectName) && params.NextPage != "" {
+		body[pageKey] = params.NextPage
+		body[pageSizeKey] = pageSize
+	}
+
+	return body
+}
+
+func (c *Connector) parseReadResponse(
+	ctx context.Context,
+	params common.ReadParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.ReadResult, error) {
+	responseKey := metadata.Schemas.LookupArrayFieldName(c.Module(), params.ObjectName)
+
+	return common.ParseResult(
+		response,
+		common.GetRecordsUnderJSONPath(responseKey),
+		makeNextRecordsURL,
+		common.GetMarshaledData,
+		params.Fields,
+	)
+}
+
+// Note: Ashby API uses POST method for all operations (create/update/delete).
+func (c *Connector) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) {
+	var (
+		url *urlbuilder.URL
+		err error
+	)
+
+	url, err = urlbuilder.New(c.ProviderInfo().BaseURL, params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	jsonData, err := json.Marshal(params.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	return http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewReader(jsonData))
+}
+
+func (c *Connector) parseWriteResponse(
+	ctx context.Context,
+	params common.WriteParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.WriteResult, error) {
+	node, ok := response.Body()
+	if !ok {
+		return &common.WriteResult{Success: true}, nil
+	}
+
+	success, err := jsonquery.New(node).BoolWithDefault("success", true)
+	if err != nil {
+		return nil, err
+	}
+
+	if !success {
+		return handleErrorResponse(node)
+	}
+
+	results, err := jsonquery.New(node).ObjectRequired("results")
+	if err != nil {
+		//nolint:nilerr
+		return &common.WriteResult{Success: true}, nil
+	}
+
+	rawID, err := jsonquery.New(node, "results").StringOptional("id")
+	if err != nil {
+		//nolint:nilerr
+		return &common.WriteResult{Success: true}, nil
+	}
+
+	data, err := jsonquery.Convertor.ObjectToMap(results)
+	if err != nil {
+		//nolint:nilerr
+		return &common.WriteResult{
+			Success:  true,
+			RecordId: *rawID,
+		}, nil
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: *rawID,
+		Data:     data,
+	}, nil
+}
+
+func handleErrorResponse(node *ajson.Node) (*common.WriteResult, error) {
+	errors, err := jsonquery.New(node).ArrayOptional("errors")
+	if err != nil {
+		return &common.WriteResult{Success: false}, nil //nolint:nilerr
+	}
+
+	errorArr, err := jsonquery.Convertor.ArrayToObjects(errors)
+	if err != nil {
+		//nolint:nilerr
+		return &common.WriteResult{Success: false}, nil
+	}
+
+	return &common.WriteResult{
+		Success: false,
+		Errors:  errorArr,
+	}, nil
+}
diff --git a/providers/ashby/parse.go b/providers/ashby/parse.go
new file mode 100644
index 00000000..86ff68bc
--- /dev/null
+++ b/providers/ashby/parse.go
@@ -0,0 +1,20 @@
+package ashby
+
+import (
+	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+func makeNextRecordsURL(node *ajson.Node) (string, error) {
+	moreDataAvailable, err := jsonquery.New(node).BoolWithDefault("moreDataAvailable", true)
+	if !moreDataAvailable || err != nil {
+		return "", nil //nolint:nilerr
+	}
+
+	cursor, err := jsonquery.New(node).StringOptional("nextCursor")
+	if err != nil {
+		return "", nil //nolint:nilerr
+	}
+
+	return *cursor, nil
+}
diff --git a/providers/ashby/supports.go b/providers/ashby/supports.go
new file mode 100644
index 00000000..70170489
--- /dev/null
+++ b/providers/ashby/supports.go
@@ -0,0 +1,57 @@
+package ashby
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/providers/ashby/metadata"
+)
+
+var (
+	// nolint:gochecknoglobals,lll
+	supportPagination = datautils.NewSet("application.list", "applicationFeedback.list", "candidate.list",
+		"candidateTag.list", "customField.list", "feedbackFormDefinition.list", "interview.list", "interviewSchedule.list",
+		"feedbackFormDefinition.list", "interviewerPool.list", "job.list", "jobTemplate.list", "offer.list", "opening.list",
+		"project.list", "surveyFormDefinition.list", "user.list")
+
+	//nolint:gochecknoglobals
+	supportSince = datautils.NewSet("application.list", "applicationFeedback.list", "interviewSchedule.list")
+)
+
+func supportedOperations() components.EndpointRegistryInput {
+	readSupport := metadata.Schemas.ObjectNames().GetList(staticschema.RootModuleID)
+
+	//nolint:lll
+	writeSupport := []string{
+		"application.create", "candidate.create", "candidate.createNote", "candidate.addTag",
+		"candidate.addProject", "candidateTag.create", "customField.create", "department.create",
+		"hiringTeam.addMember", "interviewSchedule.create", "interviewerPool.create", "interviewerPool.addUser",
+		"job.create", "location.create", "offer.create", "offerProcess.start", "opening.create", "referral.create",
+		"surveyRequest.create", "surveySubmission.create", "webhook.create",
+
+		"application.change_source", "application.change_stage", "application.transfer", "application.update",
+		"application.updateHistory", "application.addHiringTeamMember", "application.removeHiringTeamMember",
+		"applicationFeedback.submit", "applicationForm.submit", "approvalDefinition.update", "assessment.addCompletedToCandidate",
+		"assessment.start", "assessment.update", "assessment.cancel", "candidate.anonymize", "candidate.update", "customField.setValue",
+		"hiringTeam.addMember", "interviewSchedule.cancel", "interviewSchedule.update", "interviewerPool.update", "interviewerPool.archive",
+		"interviewerPool.restore", "interviewerPool.addUser", "interviewerPool.removeUser", "job.setStatus", "job.update", "jobPosting.update",
+		"offer.start", "opening.addJob", "opening.removeJob", "opening.addLocation", "opening.removeLocation", "opening.setOpeningState", "opening.setArchived",
+		"opening.update", "webhook.update",
+	}
+
+	return components.EndpointRegistryInput{
+		staticschema.RootModuleID: {
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(readSupport, ",")),
+				Support:  components.ReadSupport,
+			},
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(writeSupport, ",")),
+				Support:  components.WriteSupport,
+			},
+		},
+	}
+}
diff --git a/providers/brevo/connector.go b/providers/brevo/connector.go
index 3049518a..cb7f7814 100644
--- a/providers/brevo/connector.go
+++ b/providers/brevo/connector.go
@@ -1,13 +1,12 @@
 package brevo
 
 import (
-	_ "embed"
-
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/components"
 	"github.com/amp-labs/connectors/internal/components/operations"
 	"github.com/amp-labs/connectors/internal/components/reader"
 	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/internal/components/writer"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/providers/brevo/metadata"
 )
@@ -22,6 +21,7 @@ type Connector struct {
 	// supported operations
 	components.SchemaProvider
 	components.Reader
+	components.Writer
 }
 
 func NewConnector(params common.Parameters) (*Connector, error) {
@@ -37,7 +37,6 @@ func constructor(base *components.Connector) (*Connector, error) {
 	if err != nil {
 		return nil, err
 	}
-
 	// Set the metadata provider for the connector
 	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), metadata.Schemas)
 
@@ -52,8 +51,16 @@ func constructor(base *components.Connector) (*Connector, error) {
 		},
 	)
 
-	// Set the metadata provider for the connector
-	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), metadata.Schemas)
+	connector.Writer = writer.NewHTTPWriter(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.WriteHandlers{
+			BuildRequest:  connector.buildWriteRequest,
+			ParseResponse: connector.parseWriteResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
 
 	return connector, nil
 }
diff --git a/providers/brevo/handlers.go b/providers/brevo/handlers.go
index 7f0c70d9..cade235b 100644
--- a/providers/brevo/handlers.go
+++ b/providers/brevo/handlers.go
@@ -1,12 +1,17 @@
 package brevo
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
+	"fmt"
 	"net/http"
 	"strconv"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/amp-labs/connectors/internal/jsonquery"
 	"github.com/amp-labs/connectors/providers/brevo/metadata"
 )
 
@@ -58,3 +63,79 @@ func (c *Connector) parseReadResponse(
 		params.Fields,
 	)
 }
+
+func (c *Connector) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) {
+	var (
+		url    *urlbuilder.URL
+		err    error
+		method = http.MethodPost
+	)
+
+	url, err = urlbuilder.New(c.ProviderInfo().BaseURL, apiVersion, params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(params.RecordId) > 0 {
+		url.AddPath(params.RecordId)
+
+		method = http.MethodPatch
+	}
+
+	jsonData, err := json.Marshal(params.RecordData)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal record data: %w", err)
+	}
+
+	return http.NewRequestWithContext(ctx, method, url.String(), bytes.NewReader(jsonData))
+}
+
+func (c *Connector) parseWriteResponse(
+	ctx context.Context,
+	params common.WriteParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.WriteResult, error) {
+	node, ok := response.Body()
+	if !ok {
+		// Handle empty response
+		return &common.WriteResult{
+			Success: true,
+		}, nil
+	}
+
+	recordIDPaths := datautils.NewDefaultMap(map[string]string{ //nolint:gochecknoglobals
+		"smtp/email":                           "messageId",
+		"transactionalSMS/sms":                 "messageId",
+		"whatsapp/sendMessage":                 "messageId",
+		"contacts/export":                      "processId",
+		"contacts/import":                      "processId",
+		"webhooks/export":                      "processId",
+		"corporate/ssoToken":                   "token",
+		"corporate/subAccount/ssoToken":        "token",
+		"corporate/subAccount/key":             "key",
+		"organization/user/invitation/send":    "invoice_id",
+		"organization/user/update/permissions": "invoice_id",
+		"companies/import":                     "processId",
+		"crm/deals/import":                     "processId",
+		"orders/status/batch":                  "batchId",
+	},
+		func(objectName string) string {
+			return "id"
+		},
+	)
+
+	idPath := recordIDPaths.Get(params.ObjectName)
+
+	rawID, err := jsonquery.New(node).TextWithDefault(idPath, "")
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: rawID,
+		Errors:   nil,
+		Data:     nil,
+	}, nil
+}
diff --git a/providers/brevo/parse.go b/providers/brevo/parse.go
index 70fb1f37..282b6968 100644
--- a/providers/brevo/parse.go
+++ b/providers/brevo/parse.go
@@ -35,7 +35,6 @@ func nextRecordsURL(previousURL *url.URL) common.NextPageFunc {
 			}
 		}
 
-		// calcuate the next offset value
 		nextOffset := currentOffset + pageSize
 		// if nextOffset would exceed total count, we've fetched all records
 		if nextOffset >= int(*totalCount) {
diff --git a/providers/brevo/support.go b/providers/brevo/support.go
index 604fb4c2..79a2bc13 100644
--- a/providers/brevo/support.go
+++ b/providers/brevo/support.go
@@ -38,12 +38,39 @@ func supportedOperations() components.EndpointRegistryInput {
 	// We support reading everything under schema.json, so we get all the objects and join it into a pattern.
 	readSupport := metadata.Schemas.ObjectNames().GetList(staticschema.RootModuleID)
 
+	writeSupport := []string{
+		"smtp/email", "smtp/templates", "smtp/blockedDomains",
+		"smtp/deleteHardbounces", "transactionalSMS/sms", "whatsapp/sendMessage",
+		"emailCampaigns", "emailCampaigns/images", "smsCampaigns", "whatsappCampaigns",
+		"whatsappCampaigns/template", "contacts", "contacts/doubleOptinConfirmation", "contacts/folders",
+		"contacts/export", "contacts/import", "events", "senders", "senders/domains", "webhooks", "webhooks/export",
+		"corporate/subAccount", "corporate/ssoToken", "corporate/subAccount/ssoToken", "corporate/subAccount/key",
+
+		"corporate/group", "corporate/user/invitation/send",
+		"organization/user/invitation/send", "organization/user/update/permissions",
+
+		"feeds", "companies", "crm/attributes", "companies/import",
+		"crm/deals", "crm/deals/import", "crm/tasks", "crm/notes",
+		"conversations/messages",
+
+		"conversations/pushedMessages", "conversations/agentOnlinePing",
+		"ecommerce/activate", "ecommerce/config/displayCurrency",
+
+		"orders/status", "orders/status/batch", "categories", "categories/batch", "products", "couponCollections",
+		"coupons", "payments/requests", "loyalty/config/programs",
+	}
+
 	return components.EndpointRegistryInput{
 		staticschema.RootModuleID: {
 			{
 				Endpoint: fmt.Sprintf("{%s}", strings.Join(readSupport, ",")),
 				Support:  components.ReadSupport,
 			},
+
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(writeSupport, ",")),
+				Support:  components.WriteSupport,
+			},
 		},
 	}
 }
diff --git a/providers/clickup/connector.go b/providers/clickup/connector.go
new file mode 100644
index 00000000..775e8b3a
--- /dev/null
+++ b/providers/clickup/connector.go
@@ -0,0 +1,54 @@
+package clickup
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/components/operations"
+	"github.com/amp-labs/connectors/internal/components/reader"
+	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/clickup/metadata"
+)
+
+type Connector struct {
+	// Basic connector
+	*components.Connector
+
+	// Require authenticated client
+	common.RequireAuthenticatedClient
+
+	// supported operations
+	components.SchemaProvider
+	components.Reader
+}
+
+func NewConnector(params common.Parameters) (*Connector, error) {
+	// Create base connector with provider info
+	return components.Initialize(providers.ClickUp, params, constructor)
+}
+
+//nolint:funlen
+func constructor(base *components.Connector) (*Connector, error) {
+	connector := &Connector{Connector: base}
+
+	// Set the metadata provider for the connector
+	connector.SchemaProvider = schema.NewOpenAPISchemaProvider(connector.ProviderContext.Module(), metadata.Schemas)
+
+	registry, err := components.NewEndpointRegistry(supportedOperations())
+	if err != nil {
+		return nil, err
+	}
+
+	connector.Reader = reader.NewHTTPReader(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.ReadHandlers{
+			BuildRequest:  connector.buildReadRequest,
+			ParseResponse: connector.parseReadResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
+	return connector, nil
+}
diff --git a/providers/clickup/handlers.go b/providers/clickup/handlers.go
new file mode 100644
index 00000000..da7a85f9
--- /dev/null
+++ b/providers/clickup/handlers.go
@@ -0,0 +1,51 @@
+package clickup
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/providers/clickup/metadata"
+)
+
+var apiVersion = "api/v2" //nolint:gochecknoglobals
+
+func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadParams) (*http.Request, error) {
+	var (
+		url *urlbuilder.URL
+		err error
+	)
+
+	if params.NextPage != "" {
+		return http.NewRequestWithContext(ctx, http.MethodGet, params.NextPage.String(), nil)
+	}
+
+	// For first page, construct the URL
+	url, err = urlbuilder.New(c.ProviderInfo().BaseURL, apiVersion, params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	url.WithQueryParam("page", "0")
+
+	return http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+}
+
+func (c *Connector) parseReadResponse(
+	ctx context.Context,
+	params common.ReadParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.ReadResult, error) {
+	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module(), params.ObjectName)
+	requestURL := request.URL
+
+	return common.ParseResult(
+		response,
+		common.GetRecordsUnderJSONPath(responseFieldName),
+		nextRecordsURL(requestURL),
+		common.GetMarshaledData,
+		params.Fields,
+	)
+}
diff --git a/providers/clickup/metadata/schemas.json b/providers/clickup/metadata/schemas.json
new file mode 100644
index 00000000..19248dcd
--- /dev/null
+++ b/providers/clickup/metadata/schemas.json
@@ -0,0 +1,22 @@
+{
+  "modules": {
+    "root": {
+      "id": "",
+      "path": "",
+      "objects": {
+        "team": {
+          "displayName": "Team",
+          "path": "team",
+          "responseKey": "teams",
+          "fields": {
+            "id": "id",
+            "name": "name",
+            "color": "color",
+            "avatar": "avatar",
+            "members": "members"
+          }
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/clickup/metadata/scraping.go b/providers/clickup/metadata/scraping.go
new file mode 100644
index 00000000..86cebe92
--- /dev/null
+++ b/providers/clickup/metadata/scraping.go
@@ -0,0 +1,19 @@
+package metadata
+
+import (
+	_ "embed"
+
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"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()
+)
diff --git a/providers/clickup/parse.go b/providers/clickup/parse.go
new file mode 100644
index 00000000..4ce75d98
--- /dev/null
+++ b/providers/clickup/parse.go
@@ -0,0 +1,44 @@
+package clickup
+
+import (
+	"net/url"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+func nextRecordsURL(previousURL *url.URL) common.NextPageFunc {
+	return func(node *ajson.Node) (string, error) {
+		lastPage, err := jsonquery.New(node).BoolOptional("last_page")
+		if err != nil {
+			return "", err
+		}
+
+		if lastPage == nil || *lastPage {
+			return "", nil
+		}
+
+		nextURL := *previousURL
+
+		// parse the current page value form previous request url
+		query := nextURL.Query()
+		currentPage := 0
+		currentPageStr := query.Get("page")
+
+		if currentPageStr != "" {
+			currentPage, err = strconv.Atoi(currentPageStr)
+			if err != nil {
+				return "", nil //nolint:nilerr
+			}
+		}
+
+		nextPage := currentPage + 1
+
+		query.Set("page", strconv.Itoa(nextPage))
+		nextURL.RawQuery = query.Encode()
+
+		return nextURL.String(), nil
+	}
+}
diff --git a/providers/clickup/supports.go b/providers/clickup/supports.go
new file mode 100644
index 00000000..06007930
--- /dev/null
+++ b/providers/clickup/supports.go
@@ -0,0 +1,24 @@
+package clickup
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/providers/clickup/metadata"
+)
+
+func supportedOperations() components.EndpointRegistryInput {
+	// We support reading everything under schema.json, so we get all the objects and join it into a pattern.
+	readSupport := metadata.Schemas.ObjectNames().GetList(staticschema.RootModuleID)
+
+	return components.EndpointRegistryInput{
+		staticschema.RootModuleID: {
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(readSupport, ",")),
+				Support:  components.ReadSupport,
+			},
+		},
+	}
+}


Test patch:
diff --git a/test/ashby/read/main.go b/test/ashby/read/main.go
new file mode 100644
index 00000000..ba26ffa4
--- /dev/null
+++ b/test/ashby/read/main.go
@@ -0,0 +1,54 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	connTest "github.com/amp-labs/connectors/test/ashby"
+	"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.GetAshbyConnector(ctx)
+
+	sinceTime := time.Date(2024, 12, 2, 6, 14, 0, 0, time.UTC)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "application.list",
+		Fields:     connectors.Fields("id", "createdAt", "archivedAt", "source", "status"),
+		Since:      sinceTime,
+	})
+	if err != nil {
+		utils.Fail("error reading from Ashby", "error", err)
+	}
+
+	slog.Info("Reading application list..")
+	utils.DumpJSON(res, os.Stdout)
+
+	res, err = conn.Read(ctx, common.ReadParams{
+		ObjectName: "user.list",
+		Fields:     connectors.Fields("id", "firstName", "lastName", "email"),
+		Since:      sinceTime,
+		NextPage:   res.NextPage,
+	})
+	if err != nil {
+		utils.Fail("error reading from Ashby", "error", err)
+	}
+
+	slog.Info("Reading user list")
+	utils.DumpJSON(res, os.Stdout)
+
+}
diff --git a/test/ashby/write/main.go b/test/ashby/write/main.go
new file mode 100644
index 00000000..8be940ee
--- /dev/null
+++ b/test/ashby/write/main.go
@@ -0,0 +1,90 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers/ashby"
+	hs "github.com/amp-labs/connectors/test/ashby"
+	"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 := hs.GetAshbyConnector(ctx)
+
+	slog.Info("> TEST Create Application")
+
+	if err := createApplication(ctx, conn); err != nil {
+		slog.Error(err.Error())
+	}
+
+	slog.Info("> TEST Create Candidate Note")
+	if err := createCandidateNote(ctx, conn); err != nil {
+		slog.Error(err.Error())
+	}
+	slog.Info("Done")
+
+}
+
+func createApplication(ctx context.Context, conn *ashby.Connector) error {
+	config := common.WriteParams{
+		ObjectName: "application.create",
+		RecordData: map[string]any{
+			"candidateId": "bd7229a1-be3e-4e30-a538-0b95a41602d7",
+			"jobId":       "783338ea-e6ac-406a-853b-964fa75a5d62",
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(string(jsonStr))
+
+	return nil
+}
+
+func createCandidateNote(ctx context.Context, conn *ashby.Connector) error {
+	config := common.WriteParams{
+		ObjectName: "candidate.createNote",
+		RecordData: map[string]any{
+			"candidateId":       "bd7229a1-be3e-4e30-a538-0b95a41602d7",
+			"sendNotifications": false,
+			"note":              "this is a not what else ",
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(string(jsonStr))
+
+	return nil
+}
diff --git a/test/brevo/write/main.go b/test/brevo/write/main.go
new file mode 100644
index 00000000..d8356908
--- /dev/null
+++ b/test/brevo/write/main.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers/brevo"
+	hs "github.com/amp-labs/connectors/test/brevo"
+	"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 := hs.GetBrevoConnector(ctx)
+
+	if err := createSmtpEmail(ctx, conn); err != nil {
+		slog.Error(err.Error())
+	}
+
+	if err := createSmtpTemplates(ctx, conn); err != nil {
+		slog.Error(err.Error())
+	}
+
+	if err := AddBlockedDomains(ctx, conn); err != nil {
+		slog.Error(err.Error())
+	}
+}
+
+func createSmtpEmail(ctx context.Context, conn *brevo.Connector) error {
+	config := common.WriteParams{
+		ObjectName: "smtp/email",
+		RecordData: map[string]any{
+			"subject":     "Login Email confirmation",
+			"htmlContent": "<!DOCTYPE html> <html> <body> <h1>This is sample HTML</h1> </html>",
+			"sender": map[string]string{
+				"name":  "Mary from MyShop",
+				"email": "no-reply@myshop.com",
+			},
+			"to": []map[string]any{
+				{
+					"email": "dipu@withampersand.com",
+					"name":  "this is a test lol",
+				},
+			},
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(string(jsonStr))
+
+	return nil
+}
+
+func createSmtpTemplates(ctx context.Context, conn *brevo.Connector) error {
+	config := common.WriteParams{
+		ObjectName: "smtp/templates",
+		RecordData: map[string]any{
+			"sender": map[string]string{
+				"name":  "This is test",
+				"email": "integration.user+Brevo@withampersand.com",
+			},
+			"subject":      "Thanks for your purchase !",
+			"templateName": "Order Confirmation - EN",
+			"htmlContent":  "The order n°xxxxx has been confirmed. Thanks for your purchase",
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(string(jsonStr))
+
+	return nil
+}
+
+func AddBlockedDomains(ctx context.Context, conn *brevo.Connector) error {
+	config := common.WriteParams{
+		ObjectName: "smtp/blockedDomains",
+		RecordData: map[string]any{
+			"domain": "chaurfdfdasdfdiy.com",
+		},
+	}
+
+	result, err := conn.Write(ctx, config)
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(result, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	fmt.Println(string(jsonStr))
+
+	return nil
+}
diff --git a/test/clickup/connector.go b/test/clickup/connector.go
new file mode 100644
index 00000000..87e4603b
--- /dev/null
+++ b/test/clickup/connector.go
@@ -0,0 +1,56 @@
+package clickup
+
+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/clickup"
+	"github.com/amp-labs/connectors/test/utils"
+	"golang.org/x/oauth2"
+)
+
+func GetClickupConnector(ctx context.Context) *clickup.Connector {
+	filePath := credscanning.LoadPath(providers.ClickUp)
+	reader := utils.MustCreateProvCredJSON(filePath, true, false)
+
+	options := []common.OAuthOption{
+		common.WithOAuthClient(http.DefaultClient),
+		common.WithOAuthConfig(getConfig(reader)),
+		common.WithOAuthToken(&oauth2.Token{
+			AccessToken:  reader.Get(credscanning.Fields.AccessToken),
+			RefreshToken: reader.Get(credscanning.Fields.RefreshToken),
+		}),
+	}
+
+	client, err := common.NewOAuthHTTPClient(ctx, options...)
+	if err != nil {
+		utils.Fail(err.Error())
+	}
+
+	conn, err := clickup.NewConnector(
+		common.Parameters{
+			AuthenticatedClient: client,
+		},
+	)
+	if err != nil {
+		utils.Fail("error creating clickup connector", "error", err)
+	}
+
+	return conn
+}
+
+func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     reader.Get(credscanning.Fields.ClientId),
+		ClientSecret: reader.Get(credscanning.Fields.ClientSecret),
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://app.clickup.com/api",
+			TokenURL:  "https://api.clickup.com/api/v2/oauth/token",
+			AuthStyle: oauth2.AuthStyleInHeader,
+		},
+		RedirectURL: "http://localhost:8080/callbacks/v1/oauth",
+	}
+}
diff --git a/test/clickup/metadata/main.go b/test/clickup/metadata/main.go
new file mode 100644
index 00000000..55390dce
--- /dev/null
+++ b/test/clickup/metadata/main.go
@@ -0,0 +1,25 @@
+package main
+
+import (
+	"context"
+	"log"
+	"os"
+
+	"github.com/amp-labs/connectors/test/clickup"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	ctx := context.Background()
+
+	conn := clickup.GetClickupConnector(ctx) // nolint
+
+	// nolint
+	m, err := conn.ListObjectMetadata(ctx, []string{"team"})
+
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	utils.DumpJSON(m, os.Stdout)
+}
diff --git a/test/clickup/read/main.go b/test/clickup/read/main.go
new file mode 100644
index 00000000..48cfec3a
--- /dev/null
+++ b/test/clickup/read/main.go
@@ -0,0 +1,36 @@
+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/clickup"
+	"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.GetClickupConnector(ctx)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "team",
+		Fields:     connectors.Fields("id", "name", "color", "avatar"),
+	})
+	if err != nil {
+		utils.Fail("error reading from Clickup", "error", err)
+	}
+
+	slog.Info("Reading team..")
+	utils.DumpJSON(res, os.Stdout)
+}