Repository: amp-labs/connectors
Pull request #71 (2024-02-21 22:46:06)

feat: Use OpenAPI spec to hold ProviderInfo types 
* Handling YAML / file changes is trickier than expected. This PR defines an OpenAPI spec that holds the type of the provider information. 
* The Go types generated using this spec are used to store the catalog.
* This will allow us to also generate types for the FE and use the provider information 

**Note** that this is technically an 'invalid' or 'incomplete' spec that linters will give warnings for, because we are missing `paths` and some other attributes of the spec.

## Type generation
### Go
Works as expected, can be seen in `types.gen.go`

### Typescript
Using the command - 
```
openapi-generator-cli generate -i ../connectors/providers/types.yaml -g typescript-fetch -o generated-sources/providers --skip-validate-spec --additional-properties=npmName=amp-labs-generated-provider-sdk,supportsES6=true,withInterfaces=true
```
gives us - 

![Screenshot 2024-02-22 at 2 54 07 PM](https://github.com/amp-labs/connectors/assets/18614743/9b35ebc6-fbd6-4e14-9b9b-c5fbfadfbc62)

## Testing
### Remote reference works
This spec can be remotely referred to using the full URL - 
![Screenshot 2024-02-22 at 2 51 27 PM](https://github.com/amp-labs/connectors/assets/18614743/9a7d7f3d-e125-4753-87dc-50451c0b191b)

## readme.io docs work
![Screenshot 2024-02-22 at 3 06 57 PM](https://github.com/amp-labs/connectors/assets/18614743/aadf12ec-ceb4-467a-a0a2-56f83abb7121)


codesee-maps[bot]: <details open>
<summary><b>👇 <a href="https://app.codesee.io/r/reviews?pr=71&src=https%3A%2F%2Fgithub.com%2Famp-labs%2Fconnectors">Click on the image</a> for a new way to code review</b>
</summary>

####

[![Review these changes using an interactive CodeSee Map](https://s3.us-east-2.amazonaws.com/maps.codesee.io/images/github/amp-labs/connectors/71/bbea38cd/34eb775566a07e4fece9871e84e85a0e97027144.svg)](https://app.codesee.io/r/reviews?pr=71&src=https%3A%2F%2Fgithub.com%2Famp-labs%2Fconnectors)

#### Legend
<img src="https://app.codesee.io/Legend_v3.png" alt="CodeSee Map legend" width="220" height="156">
</details>

Patch:
diff --git a/providers/Makefile b/providers/Makefile
new file mode 100644
index 00000000..341fbda6
--- /dev/null
+++ b/providers/Makefile
@@ -0,0 +1,5 @@
+# Generate Go types from OpenAPI spec.
+
+.PHONY: gen
+gen:
+	oapi-codegen --config=oapi.config.yaml --o=types.gen.go types.yaml
diff --git a/providers/catalog.go b/providers/catalog.go
index 4c5adc34..861ba1fa 100644
--- a/providers/catalog.go
+++ b/providers/catalog.go
@@ -1,144 +1,72 @@
 package providers
 
-import (
-	"errors"
-	"html/template"
-	"os"
-	"path/filepath"
-	"reflect"
-	"runtime"
-	"strings"
-
-	"github.com/go-playground/validator"
-	"gopkg.in/yaml.v3"
-)
+// ================================================================================
+// Provider list
+// ================================================================================
 
 const (
-	// catalogFileRelativeLoc is the name of the providers.yaml catalog.
-	catalogFileRelativeLoc = "providers.yaml"
-)
-
-var (
-	ErrProviderCatalogNotFound = errors.New("provider catalog file not found")
-	ErrUnableToGetCallerCWD    = errors.New("unable to get caller's current working directory")
+	Salesforce Provider = "salesforce"
+	Hubspot    Provider = "hubspot"
+	LinkedIn   Provider = "linkedIn"
 )
 
-// ReadConfig reads the configuration from the config file for a specific provider. It also performs string substitution
-// on the values in the config that are surrounded by {{}}. The provider YAML has more details on how it works.
-func ReadConfig(provider Provider, substitutions *map[string]string) (*ProviderInfo, error) {
-	config, err := GetCatalog()
-	if err != nil {
-		return nil, err
-	}
-
-	providerConfig, ok := config.Providers[provider]
-	if !ok {
-		return nil, ErrProviderCatalogNotFound
-	}
-
-	// Validate the provider configuration
-	v := validator.New()
-	if err := v.Struct(&providerConfig); err != nil {
-		return nil, err
-	}
-
-	// Apply substitutions to the provider configuration values which contain variables in the form of {{var}}.
-	err = substituteStruct(&providerConfig, substitutions)
-	if err != nil {
-		return nil, err
-	}
-
-	return &providerConfig, nil
-}
-
-// GetCatalog reads the entire provider catalog.
-func GetCatalog() (*Catalog, error) {
-	// Figure out the cwd of the caller
-	_, filename, _, ok := runtime.Caller(0)
-	if !ok {
-		return nil, ErrUnableToGetCallerCWD
-	}
-
-	// Get the absolute directory of the catalog file
-	catalogDir := filepath.Dir(filename)
-
-	// Construct the absolute path to the providers.yaml file
-	yamlPath := filepath.Join(catalogDir, catalogFileRelativeLoc)
-
-	// Read the file
-	data, err := os.ReadFile(yamlPath)
-	if err != nil {
-		return nil, err
-	}
-
-	catalog := &Catalog{}
-
-	err = yaml.Unmarshal(data, catalog)
-	if err != nil {
-		return nil, err
-	}
-
-	return catalog, nil
-}
-
-// substituteStruct performs string substitution on the fields of the input struct
-// using the substitutions map.
-func substituteStruct(input interface{}, substitutions *map[string]string) (err error) {
-	configStruct := reflect.ValueOf(input).Elem()
-	for i := 0; i < configStruct.NumField(); i++ {
-		field := configStruct.Field(i)
-
-		// If the field is a string, perform substitution on it.
-		if field.Kind() == reflect.String {
-			substitutedVal, err := substitute(field.String(), substitutions)
-			if err != nil {
-				return err
-			}
-
-			field.SetString(substitutedVal)
-		}
-
-		// If the field is a struct, perform substitution on its fields.
-		if field.Kind() == reflect.Struct {
-			err := substituteStruct(field.Addr().Interface(), substitutions)
-			if err != nil {
-				return err
-			}
-		}
-
-		// If the field is a map, perform substitution on its values.
-		if field.Kind() == reflect.Map {
-			for _, key := range field.MapKeys() {
-				val := field.MapIndex(key)
-				if val.Kind() == reflect.String {
-					substitutedVal, err := substitute(val.String(), substitutions)
-					if err != nil {
-						return err
-					}
-
-					field.SetMapIndex(key, reflect.ValueOf(substitutedVal))
-				}
-			}
-		}
-	}
-
-	return nil
-}
-
-// substitute performs string substitution on the input string
-// using the substitutions map.
-func substitute(input string, substitutions *map[string]string) (string, error) {
-	tmpl, err := template.New("-").Parse(input)
-	if err != nil {
-		return "", err
-	}
-
-	var result strings.Builder
-
-	err = tmpl.Execute(&result, substitutions)
-	if err != nil {
-		return "", err
-	}
-
-	return result.String(), nil
+// ================================================================================
+// Contains critical provider configuration (using types from types.gen.go)
+// ================================================================================
+
+var Catalog = CatalogType{ // nolint:gochecknoglobals
+	// Salesforce configuration
+	Salesforce: {
+		AuthType: Oauth2,
+		BaseURL:  "https://{{.workspace}}.my.salesforce.com",
+		OauthOpts: OauthOpts{
+			AuthURL:  "https://{{.workspace}}.my.salesforce.com/services/oauth2/authorize",
+			TokenURL: "https://{{.workspace}}.my.salesforce.com/services/oauth2/token",
+		},
+		Support: Support{
+			BulkWrite: true,
+			Proxy:     true,
+			Read:      true,
+			Subscribe: false,
+			Write:     true,
+		},
+		ProviderOpts: ProviderOpts{
+			"restApiUrl": "https://{{.workspace}}.my.salesforce.com/services/data/v59.0",
+			"domain":     "{{.workspace}}.my.salesforce.com",
+		},
+	},
+
+	// Hubspot configuration
+	Hubspot: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.hubapi.com",
+		OauthOpts: OauthOpts{
+			AuthURL:  "https://app.hubspot.com/oauth/authorize",
+			TokenURL: "https://api.hubapi.com/oauth/v1/token",
+		},
+		Support: Support{
+			BulkWrite: false,
+			Proxy:     true,
+			Read:      true,
+			Subscribe: false,
+			Write:     true,
+		},
+	},
+
+	// LinkedIn configuration
+	LinkedIn: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.linkedin.com",
+		OauthOpts: OauthOpts{
+			AuthURL:  "https://www.linkedin.com/oauth/v2/authorization",
+			TokenURL: "https://www.linkedin.com/oauth/v2/accessToken",
+		},
+		Support: Support{
+			BulkWrite: false,
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
 }
diff --git a/providers/oapi.config.yaml b/providers/oapi.config.yaml
new file mode 100644
index 00000000..aeda32b2
--- /dev/null
+++ b/providers/oapi.config.yaml
@@ -0,0 +1,6 @@
+package: providers
+generate:
+  # Only generate Go types for the schemas, not server stubs.
+  models: true
+output-options:
+  skip-prune: true
diff --git a/providers/providers.go b/providers/providers.go
deleted file mode 100644
index 59d7b8cd..00000000
--- a/providers/providers.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package providers
-
-// Provider is the name of a provider.
-type Provider string
-
-// List all providers here.
-const (
-	Salesforce Provider = "salesforce"
-	Hubspot    Provider = "hubspot"
-	LinkedIn   Provider = "linkedIn"
-)
-
-// String returns the string representation of the provider.
-func (p Provider) String() string {
-	return string(p)
-}
diff --git a/providers/providers.yaml b/providers/providers.yaml
deleted file mode 100644
index be1936c9..00000000
--- a/providers/providers.yaml
+++ /dev/null
@@ -1,54 +0,0 @@
-# This file contains the configuration for supported providers.
-
-# This YAML file allows for {{.variables}} (note the period) in the configuration, which can be replaced at runtime
-# by the connector. This syntax (and more) comes from the https://pkg.go.dev/html/template package.
-
-# The types.go file has the yaml tags for the struct fields, which are used to unmarshal the yaml into the struct.
-# For example, the `base_url` field is unmarshaled into the `BaseURL` field in the struct as per the `yaml:"base_url"` tag.
-
-# Use camelCase for field names.
-
-providers:
-  salesforce:
-    baseUrl: "https://{{.workspace}}.my.salesforce.com"
-    authType: oauth2
-    oauthOpts:
-      authUrl: "https://{{.workspace}}.my.salesforce.com/services/oauth2/authorize"
-      tokenUrl: "https://{{.workspace}}.my.salesforce.com/services/oauth2/token"
-    support:
-      bulkWrite: true
-      proxy: true
-      read: true
-      subscribe: false
-      write: true
-    providerOptions:
-      # The REST API URL for Salesforce - this can be used for standard object interactions
-      restApiUrl: "https://{{.workspace}}.my.salesforce.com/services/data/v59.0"
-      # The domain for the Salesforce instance - used for pagination [todo: refactor to baseUrl]
-      domain: "{{.workspace}}.my.salesforce.com"
-
-  hubspot:
-    baseUrl: "https://api.hubapi.com"
-    authType: oauth2
-    oauthOpts:
-      authUrl: "https://app.hubspot.com/oauth/authorize"
-      tokenUrl: "https://api.hubapi.com/oauth/v1/token"
-    support:
-      bulkWrite: false
-      proxy: true
-      read: true
-      subscribe: false
-      write: true
-
-  linkedIn:
-    baseUrl: "https://api.linkedin.com"
-    authType: oauth2
-    oauthOpts:
-      authUrl: "https://www.linkedin.com/oauth/v2/authorization"
-      tokenUrl: "https://www.linkedin.com/oauth/v2/accessToken"
-    support:
-      bulkWrite: false
-      proxy: false
-      read: false
-      subscribe: false
-      write: false
diff --git a/providers/types.gen.go b/providers/types.gen.go
new file mode 100644
index 00000000..0ad13ecf
--- /dev/null
+++ b/providers/types.gen.go
@@ -0,0 +1,45 @@
+// Package providers provides primitives to interact with the openapi HTTP API.
+//
+// Code generated by github.com/deepmap/oapi-codegen version v1.15.0 DO NOT EDIT.
+package providers
+
+// Defines values for AuthType.
+const (
+	Oauth2 AuthType = "oauth2"
+)
+
+// AuthType defines model for AuthType.
+type AuthType string
+
+// CatalogType defines model for CatalogType.
+type CatalogType map[string]ProviderInfo
+
+// OauthOpts defines model for OauthOpts.
+type OauthOpts struct {
+	AuthURL  string `json:"authURL"`
+	TokenURL string `json:"tokenURL"`
+}
+
+// Provider defines model for Provider.
+type Provider = string
+
+// ProviderInfo defines model for ProviderInfo.
+type ProviderInfo struct {
+	AuthType     AuthType     `json:"authType"`
+	BaseURL      string       `json:"baseURL"`
+	OauthOpts    OauthOpts    `json:"oauthOpts"`
+	ProviderOpts ProviderOpts `json:"providerOpts"`
+	Support      Support      `json:"support"`
+}
+
+// ProviderOpts defines model for ProviderOpts.
+type ProviderOpts map[string]string
+
+// Support defines model for Support.
+type Support struct {
+	BulkWrite bool `json:"bulkWrite"`
+	Proxy     bool `json:"proxy"`
+	Read      bool `json:"read"`
+	Subscribe bool `json:"subscribe"`
+	Write     bool `json:"write"`
+}
diff --git a/providers/types.go b/providers/types.go
deleted file mode 100644
index 07fd4c73..00000000
--- a/providers/types.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package providers
-
-// Catalog is the top-level structure of the configuration file.
-type Catalog struct {
-	Providers map[Provider]ProviderInfo `yaml:"providers"`
-}
-
-// ProviderInfo is the configuration for a specific provider.  We use reflection to substitute any variables
-// in the configuration. The substitution is only done on string fields. If you want to use pointers in the struct,
-// you might have to update the code to handle it.
-type ProviderInfo struct {
-	Support      ConnectorSupport  `validate:"required" yaml:"support"`
-	AuthType     AuthType          `validate:"required" yaml:"authType"`
-	BaseURL      string            `validate:"required" yaml:"baseUrl"`
-	OauthOpts    OauthOpts         `yaml:"oauthOpts"`
-	ProviderOpts map[string]string `yaml:"providerOptions,omitempty"`
-}
-
-type ConnectorSupport struct {
-	Read      bool `yaml:"read"`
-	Write     bool `yaml:"write"`
-	BulkWrite bool `yaml:"bulkWrite"`
-	Subscribe bool `yaml:"subscribe"`
-	Proxy     bool `yaml:"proxy"`
-}
-
-type OauthOpts struct {
-	AuthURL  string `yaml:"authUrl"`
-	TokenURL string `yaml:"tokenUrl"`
-}
-
-type AuthType string
-
-const (
-	AuthTypeOAuth2 AuthType = "oauth2"
-)
-
-func (i *ProviderInfo) GetOption(key string) (string, bool) {
-	if i.ProviderOpts == nil {
-		return "", false
-	}
-
-	val, ok := i.ProviderOpts[key]
-
-	return val, ok
-}
diff --git a/providers/types.yaml b/providers/types.yaml
new file mode 100644
index 00000000..0fdf005c
--- /dev/null
+++ b/providers/types.yaml
@@ -0,0 +1,86 @@
+# ==================================
+# DO NOT EDIT THIS FILE unless you are sure of the dependencies as this
+# schema is being referenced in other specs.
+# ==================================
+
+# This file contains the schema for the provider catalog and helps generate
+# the Go types for the schema. The schema is used to create a provider catalog.
+
+openapi: 3.0.0
+
+info:
+  version: 1.0.0
+  title: Provider catalog
+
+components:
+  schemas:
+    AuthType:
+      type: string
+      enum: [oauth2]
+
+    Support:
+      type: object
+      required:
+        - bulkWrite
+        - proxy
+        - read
+        - subscribe
+        - write
+      properties:
+        bulkWrite:
+          type: boolean
+        proxy:
+          type: boolean
+        read:
+          type: boolean
+        subscribe:
+          type: boolean
+        write:
+          type: boolean
+
+    OauthOpts:
+      type: object
+      required:
+          - authURL
+          - tokenURL
+      properties:
+        authURL:
+          type: string
+          example: https://login.salesforce.com/services/oauth2/authorize
+        tokenURL:
+          type: string
+          example: https://login.salesforce.com/services/oauth2/token
+
+    ProviderOpts:
+        type: object
+        additionalProperties:
+          type: string
+
+    Provider:
+      type: string
+      example: salesforce
+
+    ProviderInfo:
+      type: object
+      required:
+        - baseURL
+        - authType
+        - support
+        - oauthOpts
+        - providerOpts
+      properties:
+        authType:
+          $ref: '#/components/schemas/AuthType'
+        baseURL:
+          type: string
+        oauthOpts:
+            $ref: '#/components/schemas/OauthOpts'
+        support:
+            $ref: '#/components/schemas/Support'
+        providerOpts:
+            $ref: '#/components/schemas/ProviderOpts'
+
+    CatalogType:
+      type: object
+      additionalProperties:
+        $ref: '#/components/schemas/ProviderInfo'
diff --git a/providers/utils.go b/providers/utils.go
new file mode 100644
index 00000000..35cce5ed
--- /dev/null
+++ b/providers/utils.go
@@ -0,0 +1,107 @@
+package providers
+
+import (
+	"errors"
+	"html/template"
+	"reflect"
+	"strings"
+
+	"github.com/go-playground/validator"
+)
+
+var ErrProviderCatalogNotFound = errors.New("provider or provider catalog not found")
+
+// ReadConfig reads the configuration from the catalog for specific provider. It also performs string substitution
+// on the values in the config that are surrounded by {{}}.
+func ReadConfig(provider Provider, substitutions *map[string]string) (*ProviderInfo, error) {
+	providerConfig, ok := Catalog[provider]
+	if !ok {
+		return nil, ErrProviderCatalogNotFound
+	}
+
+	// Validate the provider configuration
+	v := validator.New()
+	if err := v.Struct(&providerConfig); err != nil {
+		return nil, err
+	}
+
+	// Apply substitutions to the provider configuration values which contain variables in the form of {{var}}.
+	err := substituteStruct(&providerConfig, substitutions)
+	if err != nil {
+		return nil, err
+	}
+
+	return &providerConfig, nil
+}
+
+// substituteStruct performs string substitution on the fields of the input struct
+// using the substitutions map.
+func substituteStruct(input interface{}, substitutions *map[string]string) (err error) {
+	configStruct := reflect.ValueOf(input).Elem()
+	for i := 0; i < configStruct.NumField(); i++ {
+		field := configStruct.Field(i)
+
+		// If the field is a string, perform substitution on it.
+		if field.Kind() == reflect.String {
+			substitutedVal, err := substitute(field.String(), substitutions)
+			if err != nil {
+				return err
+			}
+
+			field.SetString(substitutedVal)
+		}
+
+		// If the field is a struct, perform substitution on its fields.
+		if field.Kind() == reflect.Struct {
+			err := substituteStruct(field.Addr().Interface(), substitutions)
+			if err != nil {
+				return err
+			}
+		}
+
+		// If the field is a map, perform substitution on its values.
+		if field.Kind() == reflect.Map {
+			for _, key := range field.MapKeys() {
+				val := field.MapIndex(key)
+				if val.Kind() == reflect.String {
+					substitutedVal, err := substitute(val.String(), substitutions)
+					if err != nil {
+						return err
+					}
+
+					field.SetMapIndex(key, reflect.ValueOf(substitutedVal))
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+// substitute performs string substitution on the input string
+// using the substitutions map.
+func substitute(input string, substitutions *map[string]string) (string, error) {
+	tmpl, err := template.New("-").Parse(input)
+	if err != nil {
+		return "", err
+	}
+
+	var result strings.Builder
+
+	err = tmpl.Execute(&result, substitutions)
+	if err != nil {
+		return "", err
+	}
+
+	return result.String(), nil
+}
+
+func (i *ProviderInfo) GetOption(key string) (string, bool) {
+	if i.ProviderOpts == nil {
+		return "", false
+	}
+
+	val, ok := i.ProviderOpts[key]
+
+	return val, ok
+}
diff --git a/salesforce/events.go b/salesforce/events.go
index d97445f2..459890c3 100644
--- a/salesforce/events.go
+++ b/salesforce/events.go
@@ -258,8 +258,6 @@ func (c *Connector) postToSFAPI(ctx context.Context, body any, path string, enti
 		return nil, err
 	}
 
-	fmt.Println("location: ", location)
-
 	resp, err := c.post(ctx, location, body)
 	if err != nil {
 		return nil, err


Test patch:
diff --git a/providers/catalog_test.go b/providers/catalog_test.go
index baa0e0dd..d21595cb 100644
--- a/providers/catalog_test.go
+++ b/providers/catalog_test.go
@@ -5,106 +5,106 @@ import (
 	"testing"
 )
 
-func TestReadConfig(t *testing.T) { //nolint:funlen
-	t.Parallel()
-
-	// Define test cases
-	testCases := []struct {
-		provider      Provider
-		description   string
-		substitutions map[string]string
-		expected      *ProviderInfo
-		expectedErr   error
-	}{
-		{
-			provider:    Salesforce,
-			description: "Salesforce provider config with valid & invalid substitutions",
-			substitutions: map[string]string{
-				"workspace": "example",
-				"version":   "-1.0",
+// All test cases.
+var testCases = []struct { // nolint
+	provider      Provider
+	description   string
+	substitutions map[string]string
+	expected      *ProviderInfo
+	expectedErr   error
+}{
+	{
+		provider:    Salesforce,
+		description: "Salesforce provider config with valid & invalid substitutions",
+		substitutions: map[string]string{
+			"workspace": "example",
+			"version":   "-1.0",
+		},
+		expected: &ProviderInfo{
+			Support: Support{
+				Read:      true,
+				Write:     true,
+				BulkWrite: true,
+				Subscribe: false,
+				Proxy:     true,
 			},
-			expected: &ProviderInfo{
-				Support: ConnectorSupport{
-					Read:      true,
-					Write:     true,
-					BulkWrite: true,
-					Subscribe: false,
-					Proxy:     true,
-				},
-				AuthType: AuthTypeOAuth2,
-				OauthOpts: OauthOpts{
-					AuthURL:  "https://example.my.salesforce.com/services/oauth2/authorize",
-					TokenURL: "https://example.my.salesforce.com/services/oauth2/token",
-				},
-				BaseURL: "https://example.my.salesforce.com",
-				ProviderOpts: map[string]string{
-					"restApiUrl": "https://example.my.salesforce.com/services/data/v59.0",
-					"domain":     "example.my.salesforce.com",
-				},
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				AuthURL:  "https://example.my.salesforce.com/services/oauth2/authorize",
+				TokenURL: "https://example.my.salesforce.com/services/oauth2/token",
 			},
-			expectedErr: nil,
+			BaseURL: "https://example.my.salesforce.com",
+			ProviderOpts: ProviderOpts{
+				"restApiUrl": "https://example.my.salesforce.com/services/data/v59.0",
+				"domain":     "example.my.salesforce.com",
+			},
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Hubspot,
+		description: "Valid hubspot provider config with non-existent substitutions",
+		substitutions: map[string]string{
+			"nonexistentvar": "test",
 		},
-		{
-			provider:    Hubspot,
-			description: "Valid hubspot provider config with non-existent substitutions",
-			substitutions: map[string]string{
-				"nonexistentvar": "test",
+		expected: &ProviderInfo{
+			Support: Support{
+				Read:      true,
+				Write:     true,
+				BulkWrite: false,
+				Subscribe: false,
+				Proxy:     true,
 			},
-			expected: &ProviderInfo{
-				Support: ConnectorSupport{
-					Read:      true,
-					Write:     true,
-					BulkWrite: false,
-					Subscribe: false,
-					Proxy:     true,
-				},
-				AuthType: AuthTypeOAuth2,
-				OauthOpts: OauthOpts{
-					AuthURL:  "https://app.hubspot.com/oauth/authorize",
-					TokenURL: "https://api.hubapi.com/oauth/v1/token",
-				},
-				BaseURL: "https://api.hubapi.com",
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				AuthURL:  "https://app.hubspot.com/oauth/authorize",
+				TokenURL: "https://api.hubapi.com/oauth/v1/token",
 			},
-			expectedErr: nil,
+			BaseURL: "https://api.hubapi.com",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    LinkedIn,
+		description: "Valid LinkedIn provider config with non-existent substitutions",
+		substitutions: map[string]string{
+			"nonexistentvar": "xyz",
 		},
-		{
-			provider:    LinkedIn,
-			description: "Valid LinkedIn provider config with non-existent substitutions",
-			substitutions: map[string]string{
-				"nonexistentvar": "xyz",
+		expected: &ProviderInfo{
+			Support: Support{
+				Read:      false,
+				Write:     false,
+				BulkWrite: false,
+				Subscribe: false,
+				Proxy:     false,
 			},
-			expected: &ProviderInfo{
-				Support: ConnectorSupport{
-					Read:      false,
-					Write:     false,
-					BulkWrite: false,
-					Subscribe: false,
-					Proxy:     false,
-				},
-				AuthType: AuthTypeOAuth2,
-				OauthOpts: OauthOpts{
-					AuthURL:  "https://www.linkedin.com/oauth/v2/authorization",
-					TokenURL: "https://www.linkedin.com/oauth/v2/accessToken",
-				},
-				BaseURL: "https://api.linkedin.com",
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				AuthURL:  "https://www.linkedin.com/oauth/v2/authorization",
+				TokenURL: "https://www.linkedin.com/oauth/v2/accessToken",
 			},
-			expectedErr: nil,
+			BaseURL: "https://api.linkedin.com",
 		},
-		{
-			provider:    Provider("nonexistent"),
-			description: "Non-existent provider config",
-			substitutions: map[string]string{
-				"workspace": "test",
-			},
-			expected:    nil,
-			expectedErr: ErrProviderCatalogNotFound,
+		expectedErr: nil,
+	},
+	{
+		provider:    "nonexistent",
+		description: "Non-existent provider config",
+		substitutions: map[string]string{
+			"workspace": "test",
 		},
-	}
+		expected:    nil,
+		expectedErr: ErrProviderCatalogNotFound,
+	},
+}
+
+func TestReadConfig(t *testing.T) { // nolint
+	t.Parallel()
 
 	for _, tc := range testCases {
 		tc := tc // nolint:varnamelen
 
-		t.Run(string(tc.provider), func(t *testing.T) {
+		t.Run(tc.provider, func(t *testing.T) {
 			t.Parallel()
 
 			config, err := ReadConfig(tc.provider, &tc.substitutions)
@@ -113,7 +113,7 @@ func TestReadConfig(t *testing.T) { //nolint:funlen
 				t.Errorf("[%s] Expected error: %v, but got: %v", tc.description, tc.expectedErr, err)
 			}
 
-			if tc.expectedErr == nil && config != nil {
+			if tc.expectedErr == nil && config != nil { // nolint
 				if config.Support != tc.expected.Support {
 					t.Errorf("[%s] Expected support: %v, but got: %v", tc.description, tc.expected.Support, config.Support)
 				}
@@ -130,9 +130,16 @@ func TestReadConfig(t *testing.T) { //nolint:funlen
 					t.Errorf("[%s] Expected base URL: %s, but got: %s", tc.description, tc.expected.BaseURL, config.BaseURL)
 				}
 
-				for k, v := range config.ProviderOpts {
-					if tc.expected.ProviderOpts[k] != v {
-						t.Errorf("[%s] Expected provider option %s: %s, but got: %s", tc.description, k, tc.expected.ProviderOpts[k], v)
+				if config.ProviderOpts != nil {
+					for k, v := range config.ProviderOpts {
+						candidateValue, ok := tc.expected.GetOption(k)
+						if !ok {
+							t.Errorf("[%s] Unexpected option: %s", tc.description, k)
+						}
+
+						if v != candidateValue {
+							t.Errorf("[%s] Expected option %s: %s, but got: %s", tc.description, k, candidateValue, v)
+						}
 					}
 				}
 			}