diff --git a/providers/hubspot/connector.go b/providers/hubspot/connector.go index 7ab10f451..821950943 100644 --- a/providers/hubspot/connector.go +++ b/providers/hubspot/connector.go @@ -70,14 +70,12 @@ func constructor(base *components.Connector) (*Connector, error) { }, ) - connector.customAdapter = custom.NewAdapter(connector.JSONHTTPClient(), connector.ModuleInfo()) - associationsStrategy := associations.NewStrategy( - connector.JSONHTTPClient(), connector.ModuleInfo(), connector.ProviderInfo(), - ) + connector.customAdapter = custom.NewAdapter(connector.JSONHTTPClient(), connector.ProviderInfo()) + associationsStrategy := associations.NewStrategy(connector.JSONHTTPClient(), connector.ProviderInfo()) connector.associationsFiller = associationsStrategy - connector.batchAdapter = batch.NewAdapter(connector.HTTPClient(), connector.ModuleInfo(), associationsStrategy) + connector.batchAdapter = batch.NewAdapter(connector.HTTPClient(), connector.ProviderInfo(), associationsStrategy) connector.searchStrategy = search.NewStrategy( - connector.JSONHTTPClient(), connector.ModuleInfo(), connector.associationsFiller, + connector.JSONHTTPClient(), connector.ProviderInfo(), connector.associationsFiller, ) return connector, nil diff --git a/providers/hubspot/delete.go b/providers/hubspot/delete.go index bb349feac..11cde9c6f 100644 --- a/providers/hubspot/delete.go +++ b/providers/hubspot/delete.go @@ -10,7 +10,7 @@ import ( ) func (c *Connector) buildDeleteRequest(ctx context.Context, params common.DeleteParams) (*http.Request, error) { - url, err := c.getDeleteURL(params.ObjectName, params.RecordId) + url, err := c.getCRMObjectsDeleteURL(params.ObjectName, params.RecordId) if err != nil { return nil, err } diff --git a/providers/hubspot/internal/associations/create_test.go b/providers/hubspot/internal/associations/create_test.go index 9a792809b..5cf073f83 100644 --- a/providers/hubspot/internal/associations/create_test.go +++ b/providers/hubspot/internal/associations/create_test.go @@ -196,7 +196,7 @@ func constructTestStrategy(serverURL string) (*Strategy, error) { transport.SetUnitTestMockServerBaseURL(serverURL) - return NewStrategy(transport.JSONHTTPClient(), transport.ModuleInfo(), transport.ProviderInfo()), nil + return NewStrategy(transport.JSONHTTPClient(), transport.ProviderInfo()), nil } type ( diff --git a/providers/hubspot/internal/associations/strategy.go b/providers/hubspot/internal/associations/strategy.go index 061e02488..2f486aea2 100644 --- a/providers/hubspot/internal/associations/strategy.go +++ b/providers/hubspot/internal/associations/strategy.go @@ -9,16 +9,12 @@ import ( type Strategy struct { clientCRM *common.JSONHTTPClient - moduleInfo *providers.ModuleInfo providerInfo *providers.ProviderInfo } -func NewStrategy( - hubspotCRMClient *common.JSONHTTPClient, moduleInfo *providers.ModuleInfo, providerInfo *providers.ProviderInfo, -) *Strategy { +func NewStrategy(hubspotCRMClient *common.JSONHTTPClient, providerInfo *providers.ProviderInfo) *Strategy { return &Strategy{ clientCRM: hubspotCRMClient, - moduleInfo: moduleInfo, providerInfo: providerInfo, } } @@ -28,7 +24,7 @@ func NewStrategy( // nolint:lll // https://developers.hubspot.com/docs/api-reference/latest/crm/associations/associate-records/batch/get-associations func (s Strategy) getReadAssociationsURL(fromObject, toObject string) (*urlbuilder.URL, error) { - return urlbuilder.New(s.moduleInfo.BaseURL, + return urlbuilder.New(s.providerInfo.BaseURL, "crm", "associations", core.APIVersion2026March, fromObject, toObject, "batch/read") } @@ -37,7 +33,7 @@ func (s Strategy) getReadAssociationsURL(fromObject, toObject string) (*urlbuild // nolint:lll // https://developers.hubspot.com/docs/api-reference/latest/crm/associations/associate-records/batch/create-associations-labeled func (s Strategy) getCreateAssociationsURL(fromObject, toObject string) (*urlbuilder.URL, error) { - return urlbuilder.New(s.moduleInfo.BaseURL, + return urlbuilder.New(s.providerInfo.BaseURL, "crm", "associations", core.APIVersion2026March, fromObject, toObject, "batch/create") } diff --git a/providers/hubspot/internal/batch/adapter.go b/providers/hubspot/internal/batch/adapter.go index 119a328b4..401f5a3e6 100644 --- a/providers/hubspot/internal/batch/adapter.go +++ b/providers/hubspot/internal/batch/adapter.go @@ -16,8 +16,8 @@ import ( // It abstracts API endpoint construction, versioning, and JSON response processing // specific to the HubSpot Batch feature. type Adapter struct { - Client *common.JSONHTTPClient - moduleInfo *providers.ModuleInfo + Client *common.JSONHTTPClient + providerInfo *providers.ProviderInfo // Batch updating objects does not support manipulating associations. // associationsStrategy is used to create associations as a follow up. @@ -26,7 +26,7 @@ type Adapter struct { // NewAdapter creates a new batch Adapter configured to work with Hubspot's APIs. func NewAdapter( - hubspotCRMClient *common.HTTPClient, moduleInfo *providers.ModuleInfo, + hubspotCRMClient *common.HTTPClient, providerInfo *providers.ProviderInfo, associationsStrategy *associations.Strategy, ) *Adapter { shouldHandleError := func(response *http.Response) bool { @@ -50,21 +50,17 @@ func NewAdapter( return &Adapter{ Client: jsonHTTPClient, - moduleInfo: moduleInfo, + providerInfo: providerInfo, associationsStrategy: associationsStrategy, } } -func (a *Adapter) getModuleURL() string { - return a.moduleInfo.BaseURL -} - // getCreateURL builds the HubSpot batch create endpoint for the given object type. // // nolint:lll // Contacts example: https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/batch/create-contacts func (a *Adapter) getCreateURL(objectName common.ObjectName) (*urlbuilder.URL, error) { - return urlbuilder.New(a.getModuleURL(), "objects", core.APIVersion2026March, objectName.String(), "batch/create") + return urlbuilder.New(a.providerInfo.BaseURL, "crm", "objects", core.APIVersion2026March, objectName.String(), "batch/create") } // getUpdateURL builds the HubSpot batch update endpoint for the given object type. @@ -72,5 +68,5 @@ func (a *Adapter) getCreateURL(objectName common.ObjectName) (*urlbuilder.URL, e // nolint:lll // Contacts example: https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/batch/update-contacts func (a *Adapter) getUpdateURL(objectName common.ObjectName) (*urlbuilder.URL, error) { - return urlbuilder.New(a.getModuleURL(), "objects", core.APIVersion2026March, objectName.String(), "batch/update") + return urlbuilder.New(a.providerInfo.BaseURL, "crm", "objects", core.APIVersion2026March, objectName.String(), "batch/update") } diff --git a/providers/hubspot/internal/custom/adapter.go b/providers/hubspot/internal/custom/adapter.go index 41e60e085..045d42b3c 100644 --- a/providers/hubspot/internal/custom/adapter.go +++ b/providers/hubspot/internal/custom/adapter.go @@ -8,37 +8,37 @@ import ( ) type Adapter struct { - Client *common.JSONHTTPClient - moduleInfo *providers.ModuleInfo + Client *common.JSONHTTPClient + providerInfo *providers.ProviderInfo } -func NewAdapter(client *common.JSONHTTPClient, moduleInfo *providers.ModuleInfo) *Adapter { +func NewAdapter(client *common.JSONHTTPClient, providerInfo *providers.ProviderInfo) *Adapter { return &Adapter{ - Client: client, - moduleInfo: moduleInfo, + Client: client, + providerInfo: providerInfo, } } // https://developers.hubspot.com/docs/api-reference/latest/crm/properties/batch/create-properties // Note: Version APIVersion2026March is NOT FOUND at the moment for this endpoint. Using older V3. func (a *Adapter) getPropertyBatchCreateURL(objectName string) (*urlbuilder.URL, error) { - return urlbuilder.New(a.moduleInfo.BaseURL, core.APIVersion3, "properties", objectName, "/batch/create") + return urlbuilder.New(a.providerInfo.BaseURL, "crm", core.APIVersion3, "properties", objectName, "/batch/create") } // https://developers.hubspot.com/docs/api-reference/latest/crm/properties/update-property // Note: Version APIVersion2026March is NOT FOUND at the moment for this endpoint. Using older V3. func (a *Adapter) getPropertyUpdateURL(objectName, propertyName string) (*urlbuilder.URL, error) { - return urlbuilder.New(a.moduleInfo.BaseURL, core.APIVersion3, "properties", objectName, propertyName) + return urlbuilder.New(a.providerInfo.BaseURL, "crm", core.APIVersion3, "properties", objectName, propertyName) } // https://developers.hubspot.com/docs/api-reference/latest/crm/properties/property-groups/get-property // Note: Version APIVersion2026March is NOT FOUND at the moment for this endpoint. Using older V3. func (a *Adapter) getPropertyGroupNameURL(objectName, groupName string) (*urlbuilder.URL, error) { - return urlbuilder.New(a.moduleInfo.BaseURL, core.APIVersion3, "properties", objectName, "groups", groupName) + return urlbuilder.New(a.providerInfo.BaseURL, "crm", core.APIVersion3, "properties", objectName, "groups", groupName) } // https://developers.hubspot.com/docs/api-reference/latest/crm/properties/property-groups/create-property // Note: Version APIVersion2026March is NOT FOUND at the moment for this endpoint. Using older V3. func (a *Adapter) getPropertyGroupNameCreationURL(objectName string) (*urlbuilder.URL, error) { - return urlbuilder.New(a.moduleInfo.BaseURL, core.APIVersion3, "properties", objectName, "groups") + return urlbuilder.New(a.providerInfo.BaseURL, "crm", core.APIVersion3, "properties", objectName, "groups") } diff --git a/providers/hubspot/internal/search/strategy.go b/providers/hubspot/internal/search/strategy.go index 4fe64d61f..725b28f1d 100644 --- a/providers/hubspot/internal/search/strategy.go +++ b/providers/hubspot/internal/search/strategy.go @@ -12,36 +12,32 @@ import ( type Strategy struct { clientCRM *common.JSONHTTPClient - moduleInfo *providers.ModuleInfo + providerInfo *providers.ProviderInfo associationsFiller associations.Filler } func NewStrategy( hubspotCRMClient *common.JSONHTTPClient, - moduleInfo *providers.ModuleInfo, + providerInfo *providers.ProviderInfo, associationsStrategy associations.Filler, ) *Strategy { return &Strategy{ clientCRM: hubspotCRMClient, // reuses error handling from Hubspot CRM connector. - moduleInfo: moduleInfo, + providerInfo: providerInfo, associationsFiller: associationsStrategy, } } -func (s Strategy) getModuleURL(paths ...string) (*urlbuilder.URL, error) { - return urlbuilder.New(s.moduleInfo.BaseURL, paths...) -} - // https://developers.hubspot.com/docs/api-reference/latest/crm/search-the-crm#make-a-search-request func (s Strategy) getObjectsAPISearchURL(objectName string) (*urlbuilder.URL, error) { - return s.getModuleURL("objects", core.APIVersion2026March, objectName, "search") + return urlbuilder.New(s.providerInfo.BaseURL, "crm", "objects", core.APIVersion2026March, objectName, "search") } func (s Strategy) getSearchURL(objectName string) (*urlbuilder.URL, error) { switch objectName { case "lists": // https://developers.hubspot.com/docs/api-reference/latest/crm/lists/guide#retrieve-by-searching-list-details - return s.getModuleURL("lists", core.APIVersion2026March, "search") + return urlbuilder.New(s.providerInfo.BaseURL, "crm", "lists", core.APIVersion2026March, "search") default: return nil, fmt.Errorf("%w: search not supported for %v", common.ErrObjectNotSupported, objectName) } diff --git a/providers/hubspot/metadata.go b/providers/hubspot/metadata.go index 98c5b4dd7..40387e272 100644 --- a/providers/hubspot/metadata.go +++ b/providers/hubspot/metadata.go @@ -122,7 +122,7 @@ func (c *Connector) getObjectMetadata(ctx context.Context, objectName string) (* func (c *Connector) getObjectMetadataFromPropertyAPI( ctx context.Context, objectName string, ) (*common.ObjectMetadata, error) { - url, err := c.getPropertiesURL(objectName) + url, err := c.getCRMPropertiesURL(objectName) if err != nil { return nil, err } @@ -519,7 +519,7 @@ type stage struct { func (c *Connector) fetchRequiredFieldsBestEffort( ctx context.Context, objectName string, fields map[string]common.FieldMetadata, ) (map[string]common.FieldMetadata, error) { - url, err := c.getObjectSchemaURL(objectName) + url, err := c.getCRMSchemaURL(objectName) if err != nil { return nil, err } diff --git a/providers/hubspot/read.go b/providers/hubspot/read.go index 0e57c174a..9687464bb 100644 --- a/providers/hubspot/read.go +++ b/providers/hubspot/read.go @@ -88,34 +88,30 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common ) } -func (c *Connector) buildReadURL(config common.ReadParams) (string, error) { - if len(config.NextPage) != 0 { +func (c *Connector) buildReadURL(params common.ReadParams) (string, error) { + if len(params.NextPage) != 0 { // If NextPage is set, then we're reading the next page of results. // All that matters is the NextPage URL, the fields are ignored. - return config.NextPage.String(), nil + return params.NextPage.String(), nil } // If NextPage is not set, then we're reading the first page of results. // We need to construct the query and then make the request. - // NB: The final slash is just to emulate prior behavior in earlier versions - // of this code. If it turns out to be unnecessary, remove it. - return c.getCRMObjectsReadURL(config) -} - -// makeCRMObjectsQueryValues returns the query for the desired read operation. -func makeCRMObjectsQueryValues(config common.ReadParams) []string { - var out []string + url, err := c.getCRMObjectsURL(params.ObjectName) + if err != nil { + return "", err + } - fields := config.Fields.List() + fields := params.Fields.List() if len(fields) != 0 { - out = append(out, "properties", strings.Join(fields, ",")) + url.WithQueryParam("properties", strings.Join(fields, ",")) } - if config.Deleted { - out = append(out, "archived", "true") + if params.Deleted { + url.WithQueryParam("archived", "true") } - out = append(out, "limit", core.DefaultPageSize) + url.WithQueryParam("limit", core.DefaultPageSize) - return out + return url.String(), nil } diff --git a/providers/hubspot/record-count.go b/providers/hubspot/record-count.go index 6a07870e3..1265255f1 100644 --- a/providers/hubspot/record-count.go +++ b/providers/hubspot/record-count.go @@ -22,9 +22,7 @@ func (c *Connector) GetRecordCount( params *common.RecordCountParams, ) (*common.RecordCountResult, error) { // Build search URL - url, err := c.getCRMObjectsSearchURL(SearchParams{ - ObjectName: params.ObjectName, - }) + url, err := c.getCRMObjectsSearchURL(params.ObjectName) if err != nil { return nil, fmt.Errorf("failed to build search URL: %w", err) } @@ -61,7 +59,7 @@ func (c *Connector) GetRecordCount( } // Execute the search request - response, err := c.JSONHTTPClient().Post(ctx, url, filterBody) + response, err := c.JSONHTTPClient().Post(ctx, url.String(), filterBody) if err != nil { return nil, fmt.Errorf("failed to execute search request: %w", err) } diff --git a/providers/hubspot/record.go b/providers/hubspot/record.go index 047898fa7..edd3b63a0 100644 --- a/providers/hubspot/record.go +++ b/providers/hubspot/record.go @@ -56,7 +56,7 @@ func (c *Connector) GetRecordsByIds( pluralObjectName := naming.NewPluralString(objectName).String() - u, err := c.getBatchRecordsURL(pluralObjectName, associationsList) + u, err := c.buildBatchRecordsURL(pluralObjectName, associationsList) if err != nil { return nil, err } @@ -88,12 +88,15 @@ func (c *Connector) GetRecordsByIds( return marshaller(records, fields) } -func (c *Connector) getBatchRecordsURL(objectName string, associations []string) (string, error) { - relativePath := strings.Join([]string{"/objects", objectName, "batch", "read"}, "/") +func (c *Connector) buildBatchRecordsURL(objectName string, associations []string) (string, error) { + url, err := c.getCRMObjectsBatchReadURL(objectName) + if err != nil { + return "", err + } if len(associations) > 0 { - return c.getURL(relativePath, "associations", strings.Join(associations, ",")) - } else { - return c.getURL(relativePath) + url.WithQueryParam("associations", strings.Join(associations, ",")) } + + return url.String(), nil } diff --git a/providers/hubspot/search.go b/providers/hubspot/search.go index 16686e1da..11305a476 100644 --- a/providers/hubspot/search.go +++ b/providers/hubspot/search.go @@ -61,12 +61,12 @@ func (c *Connector) ReadUsingSearchAPI(ctx context.Context, config SearchParams) }) } - url, err := c.getCRMObjectsSearchURL(config) + url, err := c.getCRMObjectsSearchURL(config.ObjectName) if err != nil { return nil, err } - rsp, err := c.JSONHTTPClient().Post(ctx, url, makeFilterBody(config)) + rsp, err := c.JSONHTTPClient().Post(ctx, url.String(), makeFilterBody(config)) if err != nil { return nil, err } @@ -91,7 +91,7 @@ func (c *Connector) searchCRM( return nil, err } - url, err := c.getCRMSearchURL(config) + url, err := c.getCRMSearchURL(config.ObjectName) if err != nil { return nil, err } @@ -101,7 +101,7 @@ func (c *Connector) searchCRM( return nil, err } - rsp, err := c.JSONHTTPClient().Post(ctx, url, payload) + rsp, err := c.JSONHTTPClient().Post(ctx, url.String(), payload) if err != nil { return nil, err } diff --git a/providers/hubspot/url.go b/providers/hubspot/url.go index cd814cdf0..68ed72c7e 100644 --- a/providers/hubspot/url.go +++ b/providers/hubspot/url.go @@ -1,100 +1,123 @@ package hubspot import ( - "errors" - "fmt" - "net/url" - "path" - "strings" - - "github.com/amp-labs/connectors/common" "github.com/amp-labs/connectors/common/urlbuilder" "github.com/amp-labs/connectors/providers/hubspot/internal/core" ) -var errMissingValue = errors.New("missing value for query parameter") - -// getURL is a helper to return the full URL considering the base URL & module. -// TODO: replace queryArgs with urlbuilder.New().WithQueryParam(). -func (c *Connector) getURL(arg string, queryArgs ...string) (string, error) { - baseURL := c.ModuleInfo().BaseURL - - ok := true - for ok { - // This is to satisfy the unit test, which states that trailing slashes should be removed. - baseURL, ok = strings.CutSuffix(baseURL, "/") - } - - urlBase := baseURL + "/" + path.Join(core.APIVersion3, arg) - - if len(queryArgs) > 0 { - vals := url.Values{} - - for i := 0; i < len(queryArgs); i += 2 { - key := queryArgs[i] - - if i+1 >= len(queryArgs) { - return "", fmt.Errorf("%w %q", errMissingValue, key) - } - - val := queryArgs[i+1] - - vals.Add(key, val) - } - - urlBase += "?" + vals.Encode() - } - - return urlBase, nil +// This file defines HubSpot endpoint builders used by this connector. +// +// HubSpot exposes several API families. Many connector operations live under +// the CRM namespace, but not all CRM endpoints belong to the HubSpot Objects API. +// +// In Ampersand terminology, "object" refers to a connector resource such as contacts or lists. +// In this file, "Objects" refers specifically to the HubSpot Objects API. +// It does not refer to an Ampersand object. +// +// +-----------------------------------+ +// | CRM | +// | +---------------------------+ | +// | | HubSpot Objects | | +// | | Contacts | | +// | | Leads | | +// | | Quotes | | +// | +---------------------------+ | +// | Lists | +// +-----------------------------------+ +// +// Example: +// +// - contacts belong to both the CRM namespace and the Objects API +// - lists belong to the CRM namespace but not to the Objects API +// +// The distinction matters because URL layouts differ between these endpoint families. + +// Used by GetPostAuthInfo. +// https://developers.hubspot.com/docs/api-reference/latest/account/account-information/get-account-details +func (c *Connector) getAccountDetailsURL() (*urlbuilder.URL, error) { + return urlbuilder.New(c.ProviderInfo().BaseURL, "account-info", core.APIVersion2026March, "details") } -func (c *Connector) getCRMObjectsReadURL(config common.ReadParams) (string, error) { - // NB: The final slash is just to emulate prior behavior in earlier versions - // of this code. If it turns out to be unnecessary, remove it. - relativeURL := "objects/" + config.ObjectName + "/" - - // TODO c.getURL() doesn't make a module assumption. It is not important until Hubspot will have 2+ modules. - return c.getURL(relativeURL, makeCRMObjectsQueryValues(config)...) +// Returns the schema endpoint for an object definition. +// +// Used to construct object metadata. +// Output: schemaResponse. +// +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/schemas/get-schema +func (c *Connector) getCRMSchemaURL(objectName string) (*urlbuilder.URL, error) { + return urlbuilder.New(c.ProviderInfo().BaseURL, "crm-object-schemas", core.APIVersion2026March, "schemas", objectName) } -func (c *Connector) getCRMObjectsSearchURL(config SearchParams) (string, error) { - relativeURL := strings.Join([]string{"objects", config.ObjectName, "search"}, "/") - - return c.getURL(relativeURL) +// Returns the properties endpoint for an object. +// +// Used to construct object field metadata. +// Output: fieldDescriptionResponse. +// +// This endpoint does not currently expose APIVersion2026March, so it still uses v3. +// +// https://developers.hubspot.com/docs/api-reference/latest/crm/properties/get-properties +func (c *Connector) getCRMPropertiesURL(objectName string) (*urlbuilder.URL, error) { + return c.crmURL(core.APIVersion3, "properties", objectName, "/") } -func (c *Connector) getCRMSearchURL(config searchCRMParams) (string, error) { - relativeURL := strings.Join([]string{config.ObjectName, "search"}, "/") - - return c.getURL(relativeURL) +// Returns the base HubSpot Objects API endpoint for CRUD operations. +// +// This URL shape is shared by CRUD operations. +// +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/get-contacts +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/create-contact +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/update-contact +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/delete-contact +// +// NOTE: the path layout still follows the older v3 structure. +func (c *Connector) getCRMObjectsURL(objectName string) (*urlbuilder.URL, error) { + return c.crmURL(core.APIVersion3, "objects", objectName) } -// https://developers.hubspot.com/docs/api-reference/latest/crm/properties/get-properties -// Note: Version APIVersion2026March is NOT FOUND at the moment for this endpoint. Using older V3. -func (c *Connector) getPropertiesURL(objectName string) (*urlbuilder.URL, error) { - return urlbuilder.New(c.ModuleInfo().BaseURL, core.APIVersion3, "properties", objectName, "/") +// Returns the delete endpoint for the HubSpot Objects API. +// +// TODO: replace this helper with getCRMObjectsURL once getCRMObjectsURL is migrated to APIVersion2026March. +// +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/delete-contact +func (c *Connector) getCRMObjectsDeleteURL(objectName, recordID string) (*urlbuilder.URL, error) { + return c.crmURL("objects", core.APIVersion2026March, objectName, recordID) } -// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/schemas/get-schema -func (c *Connector) getObjectSchemaURL(objectName string) (*urlbuilder.URL, error) { - return urlbuilder.New(c.getRootProviderURL(), "crm-object-schemas", core.APIVersion2026March, "schemas", objectName) +// Returns the batch read endpoint for the HubSpot Objects API. +// +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/batch/get-contacts +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/leads/batch/get-leads +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/quotes/batch/get-quotes +func (c *Connector) getCRMObjectsBatchReadURL(objectName string) (*urlbuilder.URL, error) { + return c.crmURL(core.APIVersion3, "objects", objectName, "batch", "read") } -// https://developers.hubspot.com/docs/api-reference/latest/account/account-information/get-account-details -func (c *Connector) getAccountDetailsURL() (*urlbuilder.URL, error) { - return urlbuilder.New(c.getRootProviderURL(), "account-info", core.APIVersion2026March, "details") +// Returns the search endpoint for the HubSpot Objects API. +// +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/search/search-contacts +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/leads/search/search-leads +// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/quotes/search/search-quotes +func (c *Connector) getCRMObjectsSearchURL(objectName string) (*urlbuilder.URL, error) { + return c.crmURL(core.APIVersion3, "objects", objectName, "search") } -func (c *Connector) getURLFromRoot(relativePath string) string { - return c.getRootProviderURL() + relativePath +// Returns a CRM search endpoint that does not belong to the Objects API. +// +// Some CRM resources, such as lists, live under CRM but outside the +// Objects API and therefore follow a different URL layout. +// +// https://developers.hubspot.com/docs/api-reference/latest/crm/lists/guide#retrieve-by-searching-list-details +func (c *Connector) getCRMSearchURL(objectName string) (*urlbuilder.URL, error) { + return c.crmURL(core.APIVersion3, objectName, "search") } -// Returns module agnostic Hubspot URL. -func (c *Connector) getRootProviderURL() string { - return c.ProviderInfo().BaseURL +func (c *Connector) crmURL(paths ...string) (*urlbuilder.URL, error) { + parts := append([]string{"crm"}, paths...) + + // URL: "https://api.hubapi.com/crm" + return urlbuilder.New(c.ProviderInfo().BaseURL, parts...) } -// https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/delete-contact -func (c *Connector) getDeleteURL(objectName, recordID string) (*urlbuilder.URL, error) { - return urlbuilder.New(c.ModuleInfo().BaseURL, "objects", core.APIVersion2026March, objectName, recordID) +func (c *Connector) getURLFromRoot(relativePath string) string { + return c.ProviderInfo().BaseURL + relativePath } diff --git a/providers/hubspot/url_test.go b/providers/hubspot/url_test.go deleted file mode 100644 index e86393f52..000000000 --- a/providers/hubspot/url_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package hubspot - -import ( - "testing" - - "github.com/amp-labs/connectors/common" - "github.com/amp-labs/connectors/providers" - "github.com/amp-labs/connectors/test/utils/mockutils" -) - -// nolint:funlen -func TestConnector_getURL_ModuleCRM(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - baseURL string // optional; if empty, use defaultBaseURL - arg string - queryArgs []string - wantURL string - wantErr bool - }{ - { - name: "Read with default baseURL (trailing slash removed)", - arg: "objects/contacts/", - wantURL: "https://api.hubapi.com/crm/v3/objects/contacts", - }, - { - name: "Read with query params (special chars)", - arg: "objects/contacts", - queryArgs: []string{"properties", "email,first name", "archived", "true"}, - wantURL: "https://api.hubapi.com/crm/v3/objects/contacts?archived=true&properties=email%2Cfirst+name", - }, - { - name: "Error: missing query param value", - arg: "objects/contacts", - queryArgs: []string{"properties"}, - wantErr: true, - }, - { - name: "BaseURL with extra trailing slash", - baseURL: "https://api.hubapi.com/crm/", // add an extra slash - arg: "objects/contacts/", - wantURL: "https://api.hubapi.com/crm/v3/objects/contacts", - }, - } - - for _, ttc := range cases { - t.Run(ttc.name, func(t *testing.T) { - t.Parallel() - - c, err := NewConnector( - common.ConnectorParams{ - Module: providers.ModuleHubspotCRM, - AuthenticatedClient: mockutils.NewClient(), - }, - ) - - if ttc.baseURL != "" { - c.ModuleInfo().BaseURL = ttc.baseURL - } - - gotURL, err := c.getURL(ttc.arg, ttc.queryArgs...) - if ttc.wantErr { - if err == nil { - t.Errorf("expected error, got nil") - } - - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if gotURL != ttc.wantURL { - t.Errorf("got URL %q, want %q", gotURL, ttc.wantURL) - } - }) - } -} diff --git a/providers/hubspot/write.go b/providers/hubspot/write.go index adcb4c564..1cd0e4c7d 100644 --- a/providers/hubspot/write.go +++ b/providers/hubspot/write.go @@ -2,8 +2,6 @@ package hubspot import ( "context" - "fmt" - "strings" "github.com/amp-labs/connectors/common" "github.com/amp-labs/connectors/common/logging" @@ -29,16 +27,14 @@ func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*comm var write common.WriteMethod - relativeURL := strings.Join([]string{"objects", config.ObjectName}, "/") - - url, err := c.getURL(relativeURL) + url, err := c.getCRMObjectsURL(config.ObjectName) if err != nil { return nil, err } - if config.RecordId != "" { + if config.IsUpdate() { write = c.JSONHTTPClient().Patch - url = fmt.Sprintf("%s/%s", url, config.RecordId) + url.AddPath(config.RecordId) } else { write = c.JSONHTTPClient().Post } @@ -50,7 +46,7 @@ func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*comm data["properties"] = config.RecordData data["associations"] = config.Associations - json, err := write(ctx, url, data) + json, err := write(ctx, url.String(), data) if err != nil { return nil, err }