Skip to content

Commit 07488f9

Browse files
committed
[ENG-2914] feat(hubspot): BatchWrite
1 parent deb0891 commit 07488f9

10 files changed

Lines changed: 623 additions & 3 deletions

File tree

providers/hubspot/connector.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,25 @@ import (
55
"github.com/amp-labs/connectors/common"
66
"github.com/amp-labs/connectors/common/paramsbuilder"
77
"github.com/amp-labs/connectors/providers"
8+
"github.com/amp-labs/connectors/providers/hubspot/internal/batch"
89
"github.com/amp-labs/connectors/providers/hubspot/internal/custom"
910
)
1011

11-
// Connector is a Hubspot connector.
12+
// Connector provides integration with Hubspot provider.
13+
//
14+
// The CRM module is undergoing partial migration: some operations are implemented directly within Connector,
15+
// while others are delegated to specialized sub-adapters (see below).
16+
// These sub-adapters will be consolidated as the migration completes under "crm.Adapter".
1217
type Connector struct {
1318
Client *common.JSONHTTPClient
1419
providerInfo *providers.ProviderInfo
1520
moduleInfo *providers.ModuleInfo
1621
moduleID common.ModuleID
1722

18-
// Delegate for the UpsertMetadat functionality.
19-
customAdapter *custom.Adapter
23+
// CRM module sub-adapters
24+
// These delegate specialized subsets of Hubspot CRM functionality to keep Connector modular and prevent code bloat.
25+
customAdapter *custom.Adapter // used for connectors.UpsertMetadataConnector capabilities.
26+
batchAdapter *batch.Adapter // used for connectors.BatchWriteConnector capabilities.
2027
}
2128

2229
const (
@@ -55,6 +62,7 @@ func NewConnector(opts ...Option) (conn *Connector, outErr error) {
5562
conn.moduleInfo = conn.providerInfo.ReadModuleInfo(conn.moduleID)
5663

5764
conn.customAdapter = custom.NewAdapter(conn.Client, conn.moduleInfo)
65+
conn.batchAdapter = batch.NewAdapter(conn.Client.HTTPClient, conn.moduleInfo)
5866

5967
return conn, nil
6068
}

providers/hubspot/internal/batch/README.md

Lines changed: 43 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package batch
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/amp-labs/connectors/common"
7+
"github.com/amp-labs/connectors/common/urlbuilder"
8+
"github.com/amp-labs/connectors/internal/datautils"
9+
"github.com/amp-labs/connectors/internal/httpkit"
10+
"github.com/amp-labs/connectors/providers"
11+
)
12+
13+
const apiVersion = "v3"
14+
15+
// Adapter handles batched record operations (create/update) against HubSpot's REST API.
16+
// It abstracts API endpoint construction, versioning, and JSON response processing
17+
// specific to the HubSpot Batch feature.
18+
type Adapter struct {
19+
Client *common.JSONHTTPClient
20+
moduleInfo *providers.ModuleInfo
21+
}
22+
23+
// NewAdapter creates a new batch Adapter configured to work with Hubspot's APIs.
24+
func NewAdapter(hubspotCRMClient *common.HTTPClient, moduleInfo *providers.ModuleInfo) *Adapter {
25+
shouldHandleError := func(response *http.Response) bool {
26+
// 2xx responses are normal.
27+
// 400 (Bad Request) and 409 (Conflict) are considered valid "soft failures"
28+
// because HubSpot returns structured error information for these.
29+
// Any other status (e.g., 404, 5xx) represents a provider or implementation error.
30+
allowedCodes := datautils.NewSet(http.StatusBadRequest, http.StatusConflict)
31+
32+
return !httpkit.Status2xx(response.StatusCode) &&
33+
!allowedCodes.Has(response.StatusCode)
34+
}
35+
36+
jsonHTTPClient := &common.JSONHTTPClient{
37+
HTTPClient: &common.HTTPClient{
38+
Client: hubspotCRMClient.Client, // same authentication as Hubspot CRM
39+
ErrorHandler: hubspotCRMClient.ErrorHandler, // same understanding of error format as Hubspot CRM
40+
ShouldHandleError: shouldHandleError, // differs from CRM
41+
},
42+
}
43+
44+
return &Adapter{
45+
Client: jsonHTTPClient,
46+
moduleInfo: moduleInfo,
47+
}
48+
}
49+
50+
func (a *Adapter) getModuleURL() string {
51+
return a.moduleInfo.BaseURL
52+
}
53+
54+
// getCreateURL builds the HubSpot batch create endpoint for the given object type.
55+
//
56+
// Contacts example: https://developers.hubspot.com/docs/api-reference/crm-contacts-v3/basic/get-crm-v3-objects-contacts
57+
func (a *Adapter) getCreateURL(objectName common.ObjectName) (*urlbuilder.URL, error) {
58+
return urlbuilder.New(a.getModuleURL(), apiVersion, "objects", objectName.String(), "batch/create")
59+
}
60+
61+
// getUpdateURL builds the HubSpot batch update endpoint for the given object type.
62+
//
63+
// nolint:lll
64+
// Contacts example: https://developers.hubspot.com/docs/api-reference/crm-contacts-v3/batch/post-crm-v3-objects-contacts-batch-update
65+
func (a *Adapter) getUpdateURL(objectName common.ObjectName) (*urlbuilder.URL, error) {
66+
return urlbuilder.New(a.getModuleURL(), apiVersion, "objects", objectName.String(), "batch/update")
67+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"status": "error",
3+
"message": "Contact already exists",
4+
"correlationId": "c0895ebf-9e3e-4b7d-b6e6-124e02d3f9c3",
5+
"category": "CONFLICT"
6+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
{
2+
"completedAt": "2025-11-03T23:36:13.918Z",
3+
"status": "COMPLETE",
4+
"startedAt": "2025-11-03T23:36:13.575Z",
5+
"results": [
6+
{
7+
"id": "171596044869",
8+
"properties": {
9+
"createdate": "2025-11-03T23:36:13.632Z",
10+
"email": "lilyskinner@hubspot.com",
11+
"firstname": "Lily",
12+
"hs_all_contact_vids": "171596044869",
13+
"hs_associated_target_accounts": "0",
14+
"hs_currently_enrolled_in_prospecting_agent": "false",
15+
"hs_email_domain": "hubspot.com",
16+
"hs_full_name_or_email": "Lily Skinner",
17+
"hs_is_contact": "true",
18+
"hs_is_unworked": "true",
19+
"hs_lifecyclestage_lead_date": "1762212973632",
20+
"hs_membership_has_accessed_private_content": "0",
21+
"hs_object_id": "171596044869",
22+
"hs_object_source": "INTEGRATION",
23+
"hs_object_source_id": "19083857",
24+
"hs_object_source_label": "INTEGRATION",
25+
"hs_pipeline": "contacts-lifecycle-pipeline",
26+
"hs_prospecting_agent_actively_enrolled_count": "0",
27+
"hs_prospecting_agent_total_enrolled_count": "0",
28+
"hs_registered_member": "0",
29+
"hs_sequences_actively_enrolled_count": "0",
30+
"lastmodifieddate": "2025-11-03T23:36:13.632Z",
31+
"lastname": "Skinner",
32+
"lifecyclestage": "lead",
33+
"num_notes": "0"
34+
},
35+
"createdAt": "2025-11-03T23:36:13.632Z",
36+
"updatedAt": "2025-11-03T23:36:13.632Z",
37+
"archived": false,
38+
"url": "https://app.hubspot.com/contacts/44237313/record/0-1/171596044869"
39+
},
40+
{
41+
"id": "171596044870",
42+
"properties": {
43+
"createdate": "2025-11-03T23:36:13.632Z",
44+
"email": "marleyfleming@hubspot.com",
45+
"firstname": "Marley",
46+
"hs_all_contact_vids": "171596044870",
47+
"hs_associated_target_accounts": "0",
48+
"hs_currently_enrolled_in_prospecting_agent": "false",
49+
"hs_email_domain": "hubspot.com",
50+
"hs_full_name_or_email": "Marley Fleming",
51+
"hs_is_contact": "true",
52+
"hs_is_unworked": "true",
53+
"hs_lifecyclestage_lead_date": "1762212973632",
54+
"hs_membership_has_accessed_private_content": "0",
55+
"hs_object_id": "171596044870",
56+
"hs_object_source": "INTEGRATION",
57+
"hs_object_source_id": "19083857",
58+
"hs_object_source_label": "INTEGRATION",
59+
"hs_pipeline": "contacts-lifecycle-pipeline",
60+
"hs_prospecting_agent_actively_enrolled_count": "0",
61+
"hs_prospecting_agent_total_enrolled_count": "0",
62+
"hs_registered_member": "0",
63+
"hs_sequences_actively_enrolled_count": "0",
64+
"lastmodifieddate": "2025-11-03T23:36:13.632Z",
65+
"lastname": "Fleming",
66+
"lifecyclestage": "lead",
67+
"num_notes": "0"
68+
},
69+
"createdAt": "2025-11-03T23:36:13.632Z",
70+
"updatedAt": "2025-11-03T23:36:13.632Z",
71+
"archived": false,
72+
"url": "https://app.hubspot.com/contacts/44237313/record/0-1/171596044870"
73+
}
74+
]
75+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"status": "error",
3+
"message": "Property values were not valid: [{\"isValid\":false,\"message\":\"Property \\\"f2irstname\\\" does not exist\",\"error\":\"PROPERTY_DOESNT_EXIST\",\"name\":\"f2irstname\",\"localizedErrorMessage\":\"Property \\\"f2irstname\\\" does not exist\",\"portalId\":44237313},{\"isValid\":false,\"message\":\"Property \\\"l2astname\\\" does not exist\",\"error\":\"PROPERTY_DOESNT_EXIST\",\"name\":\"l2astname\",\"localizedErrorMessage\":\"Property \\\"l2astname\\\" does not exist\",\"portalId\":44237313}]",
4+
"correlationId": "6bcb4689-467a-40ea-a072-7ed286a3f501",
5+
"errors": [
6+
{
7+
"message": "Property \"f2irstname\" does not exist",
8+
"code": "PROPERTY_DOESNT_EXIST",
9+
"context": {
10+
"propertyName": [
11+
"f2irstname"
12+
]
13+
}
14+
},
15+
{
16+
"message": "Property \"l2astname\" does not exist",
17+
"code": "PROPERTY_DOESNT_EXIST",
18+
"context": {
19+
"propertyName": [
20+
"l2astname"
21+
]
22+
}
23+
}
24+
],
25+
"category": "VALIDATION_ERROR"
26+
}

0 commit comments

Comments
 (0)