Repository: amp-labs/connectors
Pull request #61 (2024-01-30 22:54:37)

[ENG-586] Introduce providers.yaml 
* Adds `providers` package to house all providers
* Adds a `providers.ReadConfig` which uses the caller's file loc to find the `providers.yaml` file which stores all the config regarding providers. Also adds a test for it 
* Refactors connectors to use these providers instead of variables

### Follow up 
* Actually consume the config to make requests, etc
* Update server code to use `connector/provider` types

codesee-maps[bot]: <details open>
<summary><b>👇 <a href="https://app.codesee.io/r/reviews?pr=61&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/61/18f348f4/e0347c6b5acb8841dc1fcc4121c0734f7cc8520a.svg)](https://app.codesee.io/r/reviews?pr=61&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/basic.go b/basic.go
index 816fa62d..4371531c 100644
--- a/basic.go
+++ b/basic.go
@@ -5,6 +5,7 @@ import (
 	"io"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers"
 )
 
 // BasicConnector is an interface that can be used to implement a connector with
@@ -13,9 +14,9 @@ type BasicConnector interface {
 	fmt.Stringer
 	io.Closer
 
-	// Name returns the name of the connector.
-	Name() string
-
 	// HTTPClient returns the underlying HTTP client. This is useful for proxy requests.
 	HTTPClient() *common.HTTPClient
+
+	// Provider returns the connector provider.
+	Provider() providers.Provider
 }
diff --git a/connectors.go b/connectors.go
index 0dc868b9..83da9db4 100644
--- a/connectors.go
+++ b/connectors.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/hubspot"
+	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/salesforce"
 )
 
@@ -83,24 +84,16 @@ var (
 	ErrUnknownConnector = errors.New("unknown connector")
 )
 
-// APINames returns a list of supported connector names.
-func APINames() []string {
-	return []string{
-		salesforce.Name,
-		hubspot.Name,
-	}
-}
-
 // New returns a new Connector. The signature is generic to facilitate more flexible caller setup
 // (e.g. constructing a new connector based on parsing a config file, whose exact params
 // aren't known until runtime). However, if you can use the API.New form, it's preferred,
 // since you get type safety and more readable code.
 func New(apiName string, opts map[string]any) (Connector, error) { //nolint:ireturn
-	if strings.EqualFold(apiName, salesforce.Name) {
+	if strings.EqualFold(apiName, providers.Salesforce.String()) {
 		return newSalesforce(opts)
 	}
 
-	if strings.EqualFold(apiName, hubspot.Name) {
+	if strings.EqualFold(apiName, providers.Hubspot.String()) {
 		return newHubspot(opts)
 	}
 
diff --git a/hubspot/client.go b/hubspot/client.go
index 976d184f..2a878c06 100644
--- a/hubspot/client.go
+++ b/hubspot/client.go
@@ -10,3 +10,7 @@ func (c *Connector) JSONHTTPClient() *common.JSONHTTPClient {
 func (c *Connector) HTTPClient() *common.HTTPClient {
 	return c.Client.HTTPClient
 }
+
+func (c *Connector) Close() error {
+	return nil
+}
diff --git a/hubspot/close.go b/hubspot/close.go
deleted file mode 100644
index 8cab81c0..00000000
--- a/hubspot/close.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package hubspot
-
-func (c *Connector) Close() error {
-	return nil
-}
diff --git a/hubspot/get.go b/hubspot/get.go
deleted file mode 100644
index aed91436..00000000
--- a/hubspot/get.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package hubspot
-
-import (
-	"context"
-	"errors"
-	"log/slog"
-
-	"github.com/amp-labs/connectors/common"
-)
-
-// get reads data from Hubspot. It handles retries and access token refreshes.
-func (c *Connector) get(ctx context.Context, url string) (*common.JSONHTTPResponse, error) {
-	rsp, err := c.Client.Get(ctx, url)
-	if err != nil {
-		switch {
-		case errors.Is(err, common.ErrAccessToken):
-			// TODO: Retry
-			slog.Warn("Access token invalid, retrying", "error", err)
-
-			// TODO: Retry
-			return nil, err
-		case errors.Is(err, common.ErrRetryable):
-			// TODO: Retry
-			return nil, err
-		case errors.Is(err, common.ErrApiDisabled):
-			fallthrough
-		case errors.Is(err, common.ErrForbidden):
-			fallthrough
-		default:
-			// Anything else is a permanent error
-			return nil, err
-		}
-	}
-
-	// Success
-	return rsp, nil
-}
diff --git a/hubspot/post.go b/hubspot/methods.go
similarity index 53%
rename from hubspot/post.go
rename to hubspot/methods.go
index d57fcd2f..9a982f19 100644
--- a/hubspot/post.go
+++ b/hubspot/methods.go
@@ -8,6 +8,34 @@ import (
 	"github.com/amp-labs/connectors/common"
 )
 
+// get reads data from Hubspot. It handles retries and access token refreshes.
+func (c *Connector) get(ctx context.Context, url string) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Get(ctx, url)
+	if err != nil {
+		switch {
+		case errors.Is(err, common.ErrAccessToken):
+			// TODO: Retry
+			slog.Warn("Access token invalid, retrying", "error", err)
+
+			// TODO: Retry
+			return nil, err
+		case errors.Is(err, common.ErrRetryable):
+			// TODO: Retry
+			return nil, err
+		case errors.Is(err, common.ErrApiDisabled):
+			fallthrough
+		case errors.Is(err, common.ErrForbidden):
+			fallthrough
+		default:
+			// Anything else is a permanent error
+			return nil, err
+		}
+	}
+
+	// Success
+	return rsp, nil
+}
+
 // post writes data to Hubspot. It handles retries and access token refreshes.
 func (c *Connector) post(ctx context.Context, url string, body any) (*common.JSONHTTPResponse, error) {
 	rsp, err := c.Client.Post(ctx, url, body)
diff --git a/hubspot/name.go b/hubspot/name.go
deleted file mode 100644
index a8a4e955..00000000
--- a/hubspot/name.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package hubspot
-
-const Name = "hubspot"
-
-// Name returns the name of the connector.
-func (c *Connector) Name() string {
-	return Name
-}
diff --git a/hubspot/provider.go b/hubspot/provider.go
new file mode 100644
index 00000000..24b5243a
--- /dev/null
+++ b/hubspot/provider.go
@@ -0,0 +1,10 @@
+package hubspot
+
+import (
+	"github.com/amp-labs/connectors/providers"
+)
+
+// Provider returns the connector provider.
+func (c *Connector) Provider() providers.Provider {
+	return providers.Hubspot
+}
diff --git a/hubspot/string.go b/hubspot/string.go
index b17561b9..a5e6d08e 100644
--- a/hubspot/string.go
+++ b/hubspot/string.go
@@ -4,5 +4,5 @@ import "fmt"
 
 // String returns a string representation of the connector, which is useful for logging / debugging.
 func (c *Connector) String() string {
-	return fmt.Sprintf("hubspot.Connector[%s]", c.Module)
+	return fmt.Sprintf("%s.Connector[%s]", c.Provider(), c.Module)
 }
diff --git a/providers/catalog.go b/providers/catalog.go
new file mode 100644
index 00000000..be54be20
--- /dev/null
+++ b/providers/catalog.go
@@ -0,0 +1,122 @@
+package providers
+
+import (
+	"errors"
+	"html/template"
+	"os"
+	"path/filepath"
+	"reflect"
+	"runtime"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+)
+
+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")
+)
+
+// 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
+	}
+
+	// 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
+			}
+		}
+	}
+
+	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
+}
diff --git a/providers/providers.go b/providers/providers.go
new file mode 100644
index 00000000..59d7b8cd
--- /dev/null
+++ b/providers/providers.go
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 00000000..6ff3c1cf
--- /dev/null
+++ b/providers/providers.yaml
@@ -0,0 +1,46 @@
+# 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://{{.subdomain}}.salesforce.com
+    authType: oauth2
+    oauthOpts:
+      authUrl: https://{{.subdomain}}.my.salesforce.com/services/oauth2/authorize
+      tokenUrl: https://{{.subdomain}}.my.salesforce.com/services/oauth2/token
+    support:
+      bulkWrite: true
+      proxy: true
+      read: true
+      subscribe: false
+      write: true
+
+  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
+    support:
+      bulkWrite: false
+      proxy: false
+      read: false
+      subscribe: false
+      write: false
diff --git a/providers/types.go b/providers/types.go
new file mode 100644
index 00000000..b6526ee8
--- /dev/null
+++ b/providers/types.go
@@ -0,0 +1,35 @@
+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 `yaml:"support"`
+	AuthType  AuthType         `yaml:"authType"`
+	OauthOpts OauthOpts        `yaml:"oauthOpts"`
+	BaseURL   string           `yaml:"baseUrl"`
+}
+
+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"
+)
diff --git a/salesforce/client.go b/salesforce/client.go
index d5c917ee..ca0a2c53 100644
--- a/salesforce/client.go
+++ b/salesforce/client.go
@@ -10,3 +10,7 @@ func (c *Connector) JSONHTTPClient() *common.JSONHTTPClient {
 func (c *Connector) HTTPClient() *common.HTTPClient {
 	return c.Client.HTTPClient
 }
+
+func (c *Connector) Close() error {
+	return nil
+}
diff --git a/salesforce/close.go b/salesforce/close.go
deleted file mode 100644
index b753a066..00000000
--- a/salesforce/close.go
+++ /dev/null
@@ -1,5 +0,0 @@
-package salesforce
-
-func (c *Connector) Close() error {
-	return nil
-}
diff --git a/salesforce/get.go b/salesforce/get.go
deleted file mode 100644
index 3b50cc17..00000000
--- a/salesforce/get.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package salesforce
-
-import (
-	"context"
-
-	"github.com/amp-labs/connectors/common"
-)
-
-// get reads data from Salesforce. It handles retries and access token refreshes.
-func (c *Connector) get(ctx context.Context, url string) (*common.JSONHTTPResponse, error) {
-	node, err := c.Client.Get(ctx, url)
-	if err != nil {
-		return nil, c.HandleError(err)
-	}
-
-	// Success
-	return node, nil
-}
-
-// // get reads data from Salesforce. It handles retries and access token refreshes.
-// func (c *Connector) getCSV(ctx context.Context, url string) ([]byte, error) {
-// 	body, err := c.JSONClient.GetCSV(ctx, url, common.Header{
-// 		Key:   "Accept",
-// 		Value: "text/csv",
-// 	})
-// 	if err != nil {
-// 		return nil, c.HandleError(err)
-// 	}
-
-// 	return body, nil
-// }
diff --git a/salesforce/methods.go b/salesforce/methods.go
new file mode 100644
index 00000000..6dff500c
--- /dev/null
+++ b/salesforce/methods.go
@@ -0,0 +1,60 @@
+package salesforce
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+// get reads data from Salesforce. It handles retries and access token refreshes.
+func (c *Connector) get(ctx context.Context, url string) (*common.JSONHTTPResponse, error) {
+	node, err := c.Client.Get(ctx, url)
+	if err != nil {
+		return nil, c.HandleError(err)
+	}
+
+	// Success
+	return node, nil
+}
+
+func (c *Connector) putCSV(ctx context.Context, url string, body []byte) ([]byte, error) {
+	resBody, err := c.Client.PutCSV(ctx, url, body)
+	if err != nil {
+		return nil, c.HandleError(err)
+	}
+
+	return resBody, nil
+}
+
+// post writes data to Salesforce. It handles retries and access token refreshes.
+func (c *Connector) post(ctx context.Context, url string, body any) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Post(ctx, url, body)
+	if err != nil {
+		return nil, c.HandleError(err)
+	}
+
+	return rsp, nil
+}
+
+// Patch writes data to Salesforce. It handles retries and access token refreshes.
+func (c *Connector) patch(ctx context.Context, url string, body any) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Patch(ctx, url, body)
+	if err != nil {
+		return nil, c.HandleError(err)
+	}
+
+	return rsp, nil
+}
+
+// // get reads data from Salesforce. It handles retries and access token refreshes.
+// func (c *Connector) getCSV(ctx context.Context, url string) ([]byte, error) {
+// 	body, err := c.JSONClient.GetCSV(ctx, url, common.Header{
+// 		Key:   "Accept",
+// 		Value: "text/csv",
+// 	})
+// 	if err != nil {
+// 		return nil, c.HandleError(err)
+// 	}
+
+// 	return body, nil
+// }
diff --git a/salesforce/name.go b/salesforce/name.go
deleted file mode 100644
index 2af60d5f..00000000
--- a/salesforce/name.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package salesforce
-
-const Name = "salesforce"
-
-// Name returns the name of the connector.
-func (c *Connector) Name() string {
-	return Name
-}
diff --git a/salesforce/patch.go b/salesforce/patch.go
deleted file mode 100644
index eed0f80d..00000000
--- a/salesforce/patch.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package salesforce
-
-import (
-	"context"
-
-	"github.com/amp-labs/connectors/common"
-)
-
-// Patch writes data to Salesforce. It handles retries and access token refreshes.
-func (c *Connector) patch(ctx context.Context, url string, body any) (*common.JSONHTTPResponse, error) {
-	rsp, err := c.Client.Patch(ctx, url, body)
-	if err != nil {
-		return nil, c.HandleError(err)
-	}
-
-	return rsp, nil
-}
diff --git a/salesforce/post.go b/salesforce/post.go
deleted file mode 100644
index 05bf894b..00000000
--- a/salesforce/post.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package salesforce
-
-import (
-	"context"
-
-	"github.com/amp-labs/connectors/common"
-)
-
-// post writes data to Salesforce. It handles retries and access token refreshes.
-func (c *Connector) post(ctx context.Context, url string, body any) (*common.JSONHTTPResponse, error) {
-	rsp, err := c.Client.Post(ctx, url, body)
-	if err != nil {
-		return nil, c.HandleError(err)
-	}
-
-	return rsp, nil
-}
diff --git a/salesforce/provider.go b/salesforce/provider.go
new file mode 100644
index 00000000..0ae5c122
--- /dev/null
+++ b/salesforce/provider.go
@@ -0,0 +1,10 @@
+package salesforce
+
+import (
+	"github.com/amp-labs/connectors/providers"
+)
+
+// Provider returns the connector provider.
+func (c *Connector) Provider() providers.Provider {
+	return providers.Salesforce
+}
diff --git a/salesforce/put.go b/salesforce/put.go
deleted file mode 100644
index f3097ae9..00000000
--- a/salesforce/put.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package salesforce
-
-import "context"
-
-func (c *Connector) putCSV(ctx context.Context, url string, body []byte) ([]byte, error) {
-	resBody, err := c.Client.PutCSV(ctx, url, body)
-	if err != nil {
-		return nil, c.HandleError(err)
-	}
-
-	return resBody, nil
-}
diff --git a/salesforce/string.go b/salesforce/string.go
index 671b9a7d..abc04c9c 100644
--- a/salesforce/string.go
+++ b/salesforce/string.go
@@ -4,5 +4,5 @@ import "fmt"
 
 // String returns a string representation of the connector, which is useful for logging / debugging.
 func (c *Connector) String() string {
-	return fmt.Sprintf("salesforce.Connector[%s]", c.Domain)
+	return fmt.Sprintf("%s.Connector[%s]", c.Provider(), c.Domain)
 }


Test patch:
diff --git a/providers/catalog_test.go b/providers/catalog_test.go
new file mode 100644
index 00000000..6f726b18
--- /dev/null
+++ b/providers/catalog_test.go
@@ -0,0 +1,129 @@
+package providers
+
+import (
+	"errors"
+	"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{
+				"subdomain": "example",
+				"version":   "-1.0",
+			},
+			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.salesforce.com",
+			},
+			expectedErr: nil,
+		},
+		{
+			provider:    Hubspot,
+			description: "Valid hubspot provider config with non-existent substitutions",
+			substitutions: map[string]string{
+				"nonexistentvar": "test",
+			},
+			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",
+			},
+			expectedErr: nil,
+		},
+		{
+			provider:    LinkedIn,
+			description: "Valid LinkedIn provider config with non-existent substitutions",
+			substitutions: map[string]string{
+				"nonexistentvar": "xyz",
+			},
+			expected: &ProviderInfo{
+				Support: ConnectorSupport{
+					Read:      false,
+					Write:     false,
+					BulkWrite: false,
+					Subscribe: false,
+					Proxy:     false,
+				},
+				AuthType:  AuthTypeOAuth2,
+				OauthOpts: OauthOpts{},
+				BaseURL:   "https://api.linkedin.com",
+			},
+			expectedErr: nil,
+		},
+		{
+			provider:    Provider("nonexistent"),
+			description: "Non-existent provider config",
+			substitutions: map[string]string{
+				"subdomain": "test",
+			},
+			expected:    nil,
+			expectedErr: ErrProviderCatalogNotFound,
+		},
+	}
+
+	for _, tc := range testCases {
+		tc := tc // nolint:varnamelen
+
+		t.Run(string(tc.provider), func(t *testing.T) {
+			t.Parallel()
+
+			config, err := ReadConfig(tc.provider, &tc.substitutions)
+			t.Logf("Test case: %s", tc.description)
+
+			if !errors.Is(err, tc.expectedErr) {
+				t.Errorf("Expected error: %v, but got: %v", tc.expectedErr, err)
+			}
+
+			if tc.expectedErr == nil && config != nil {
+				if config.Support != tc.expected.Support {
+					t.Errorf("Expected support: %v, but got: %v", tc.expected.Support, config.Support)
+				}
+
+				if config.AuthType != tc.expected.AuthType {
+					t.Errorf("Expected auth: %v, but got: %v", tc.expected.AuthType, config.AuthType)
+				}
+
+				if config.OauthOpts != tc.expected.OauthOpts {
+					t.Errorf("Expected auth options: %v, but got: %v", tc.expected.OauthOpts, config.OauthOpts)
+				}
+
+				if config.BaseURL != tc.expected.BaseURL {
+					t.Errorf("Expected base URL: %s, but got: %s", tc.expected.BaseURL, config.BaseURL)
+				}
+			}
+		})
+	}
+}