Repository: amp-labs/connectors
Pull request #1673 (2025-05-21 22:26:08)

chore: update substitution logic to also work on Modules
Our current substitution logic does not work on pointers to maps with structs as values, so this updates the logic, and adds a test file to make sure everything still works the same.


Patch:
diff --git a/common/substitutions/substitutions.go b/common/substitutions/substitutions.go
index f52aae64..45569be0 100644
--- a/common/substitutions/substitutions.go
+++ b/common/substitutions/substitutions.go
@@ -6,75 +6,101 @@ import (
 	"text/template" // nosemgrep: go.lang.security.audit.xss.import-text-template.import-text-template
 )
 
-// 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) { //nolint:gocognit,cyclop,lll
-	configStruct := reflect.ValueOf(input).Elem()
-
-	for i := range configStruct.NumField() {
-		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
-			}
+// substituteStruct applies substitutions to all string fields in the struct pointed to by input.
+// It handles nested structs, pointers, slices, arrays, maps (including pointers-to-maps), and structs inside maps.
+func substituteStruct(input interface{}, substitutions map[string]string) error {
+	val := reflect.ValueOf(input)
+	if val.Kind() != reflect.Ptr || val.IsNil() {
+		return nil
+	}
 
-			field.SetString(substitutedVal)
+	val = val.Elem()
+	if val.Kind() != reflect.Struct {
+		return nil
+	}
+
+	for i := range val.NumField() {
+		field := val.Field(i)
+		// skip unexported or unsettable fields
+		if !field.CanSet() {
+			continue
 		}
 
-		if field.Kind() == reflect.Pointer {
-			if field.Elem().Kind() == reflect.Struct {
-				err := substituteStruct(field.Elem().Addr().Interface(), substitutions)
-				if err != nil {
-					return err
-				}
-			}
+		if err := walkValue(field, substitutions); err != nil {
+			return err
 		}
+	}
 
-		// 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 nil
+}
+
+// recursing into structs, pointers, slices, arrays, and maps (including values that are structs or pointers).
+func walkValue(val reflect.Value, substitutions map[string]string) error { //nolint:cyclop
+	switch val.Kind() { //nolint:exhaustive
+	case reflect.String:
+		s, err := substitute(val.String(), substitutions)
+		if err != nil {
+			return err
+		}
+
+		val.SetString(s)
+
+	case reflect.Pointer:
+		if val.IsNil() {
+			return nil
+		}
+		// unwrap pointers uniformly
+		return walkValue(val.Elem(), substitutions)
+
+	case reflect.Struct:
+		// recurse into nested struct
+		return substituteStruct(val.Addr().Interface(), substitutions)
+
+	case reflect.Slice, reflect.Array:
+		for i := range val.Len() {
+			if err := walkValue(val.Index(i), substitutions); 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))
-				}
+	case reflect.Map:
+		for _, key := range val.MapKeys() {
+			orig := val.MapIndex(key)
+			// wrap non-pointer values in a pointer so walkValue can mutate them
+			var ptr reflect.Value
+			if orig.Kind() == reflect.Pointer {
+				ptr = orig
+			} else {
+				ptr = reflect.New(orig.Type())
+				ptr.Elem().Set(orig)
+			}
+			// recurse into the wrapped value (handles strings, structs, nested maps, slices, etc.)
+			if err := walkValue(ptr, substitutions); err != nil {
+				return err
+			}
+			// write back for non-pointer entries (pointer entries are updated in place)
+			if orig.Kind() != reflect.Pointer {
+				val.SetMapIndex(key, ptr.Elem())
 			}
 		}
+	default:
+		return nil
 	}
 
 	return nil
 }
 
-// substitute performs string substitution on the input string
-// using the substitutions map.
+// substitute applies text/template substitution to the input string.
 func substitute(input string, substitutions map[string]string) (string, error) {
-	// missing variables are not allowed, Execute will throw an error.
 	tmpl, err := template.New("-").Option("missingkey=error").Parse(input)
 	if err != nil {
 		return "", err
 	}
 
-	var result strings.Builder
-
-	err = tmpl.Execute(&result, &substitutions)
-	if err != nil {
+	var sb strings.Builder
+	if err := tmpl.Execute(&sb, substitutions); err != nil {
 		return "", err
 	}
 
-	return result.String(), nil
+	return sb.String(), nil
 }
diff --git a/providers/aws.go b/providers/aws.go
index 3d20dea5..70adf1bd 100644
--- a/providers/aws.go
+++ b/providers/aws.go
@@ -6,7 +6,9 @@ const AWS Provider = "aws"
 
 const ModuleAWSIdentityCenter common.ModuleID = "aws-identity-center"
 
-func init() {
+const ServiceDomainPlaceholder = "<<SERVICE_DOMAIN>>"
+
+func init() { //nolint:funlen
 	SetInfo(AWS, ProviderInfo{
 		DisplayName: "Amazon Web Services",
 		AuthType:    Basic,
@@ -26,8 +28,11 @@ func init() {
 		DefaultModule: ModuleAWSIdentityCenter,
 		Modules: &Modules{
 			ModuleAWSIdentityCenter: {
-				// TODO serviceDomain changes based on the request. This is not global to the connector.
-				BaseURL:     "https://{{.serviceDomain}}.{{.region}}.amazonaws.com",
+				// TODO: The service domain changes based on the request. This is not global to the connector.
+				// This is also not a metadata field. It's decided based on the request by the connector's logic.
+				// We are special casing this for now, but we'll revisit this in the future to decide how to model this case.
+				// Using the <<>> syntax to indicate that this is a special case.
+				BaseURL:     "https://" + ServiceDomainPlaceholder + ".{{.region}}.amazonaws.com",
 				DisplayName: "AWS Identity Center",
 				Support: Support{
 					Read:      false,
diff --git a/providers/aws/internal/core/requests.go b/providers/aws/internal/core/requests.go
index 755be83f..5c5a493e 100644
--- a/providers/aws/internal/core/requests.go
+++ b/providers/aws/internal/core/requests.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/goutils"
+	"github.com/amp-labs/connectors/providers"
 )
 
 // nolint:lll
@@ -43,7 +44,7 @@ func BuildRequest(
 	}
 
 	ctx = context.WithValue(ctx, common.AWSServiceContextKey, serviceSigningName)
-	baseURL = strings.Replace(baseURL, "{{.serviceDomain}}", serviceDomain, 1)
+	baseURL = strings.Replace(baseURL, providers.ServiceDomainPlaceholder, serviceDomain, 1)
 
 	req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, reader)
 	if err != nil {
diff --git a/providers/types.gen.go b/providers/types.gen.go
index 32f72af4..7df86389 100644
--- a/providers/types.gen.go
+++ b/providers/types.gen.go
@@ -1,6 +1,6 @@
 // Package providers provides primitives to interact with the openapi HTTP API.
 //
-// Code generated by github.com/deepmap/oapi-codegen/v2 version v2.1.0 DO NOT EDIT.
+// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT.
 package providers
 
 import (
@@ -178,16 +178,28 @@ type MetadataItemInput struct {
 	// DocsURL URL with more information about how to locate this value
 	DocsURL string `json:"docsURL,omitempty"`
 
+	// ModuleDependencies Does this metadata item only apply to a specific module?
+	ModuleDependencies *ModuleDependencies `json:"moduleDependencies,omitempty"`
+
 	// Name The internal identifier for the metadata field
 	Name string `json:"name"`
 }
 
 // MetadataItemPostAuthentication defines model for MetadataItemPostAuthentication.
 type MetadataItemPostAuthentication struct {
+	// ModuleDependencies Does this metadata item only apply to a specific module?
+	ModuleDependencies *ModuleDependencies `json:"moduleDependencies,omitempty"`
+
 	// Name The internal identifier for the metadata field
 	Name string `json:"name"`
 }
 
+// ModuleDependencies Does this metadata item only apply to a specific module?
+type ModuleDependencies = map[common.ModuleID]ModuleDependency
+
+// ModuleDependency Dependency for a single module.
+type ModuleDependency = map[string]interface{}
+
 // ModuleInfo defines model for ModuleInfo.
 type ModuleInfo struct {
 	BaseURL     string `json:"baseURL"`


Test patch:
diff --git a/common/substitutions/substitutions_test.go b/common/substitutions/substitutions_test.go
new file mode 100644
index 00000000..cc739549
--- /dev/null
+++ b/common/substitutions/substitutions_test.go
@@ -0,0 +1,817 @@
+// nolint
+package substitutions
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/stretchr/testify/assert"
+)
+
+// TestSubstitute tests the basic string substitution functionality.
+func TestSubstitute(t *testing.T) {
+	tests := []struct {
+		name          string
+		input         string
+		substitutions map[string]string
+		expected      string
+		expectError   bool
+	}{
+		{
+			name:          "simple substitution",
+			input:         "https://{{.workspace}}.my.salesforce.com",
+			substitutions: map[string]string{"workspace": "test"},
+			expected:      "https://test.my.salesforce.com",
+			expectError:   false,
+		},
+		{
+			name:          "multiple substitutions",
+			input:         "https://{{.workspace}}.{{.region}}.amazonaws.com",
+			substitutions: map[string]string{"workspace": "test", "region": "us-west-2"},
+			expected:      "https://test.us-west-2.amazonaws.com",
+			expectError:   false,
+		},
+		{
+			name:          "missing variable",
+			input:         "https://{{.workspace}}.my.salesforce.com",
+			substitutions: map[string]string{},
+			expected:      "",
+			expectError:   true,
+		},
+		{
+			name:          "no substitutions needed",
+			input:         "https://api.atlassian.com",
+			substitutions: map[string]string{"workspace": "test"},
+			expected:      "https://api.atlassian.com",
+			expectError:   false,
+		},
+		{
+			name:          "invalid template",
+			input:         "https://{{.workspace.my.salesforce.com",
+			substitutions: map[string]string{"workspace": "test"},
+			expected:      "",
+			expectError:   true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := substitute(tt.input, tt.substitutions)
+			if tt.expectError {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+				assert.Equal(t, tt.expected, result)
+			}
+		})
+	}
+}
+
+// TestSubstituteStruct tests the struct substitution functionality.
+func TestSubstituteStruct(t *testing.T) {
+	type NestedStruct struct {
+		URL string
+	}
+
+	type TestStruct struct {
+		BaseURL   string
+		AuthURL   string
+		TokenURL  string
+		Nested    NestedStruct
+		NestedPtr *NestedStruct
+		URLMap    map[string]string
+		NonString int
+	}
+
+	tests := []struct {
+		name          string
+		input         *TestStruct
+		substitutions map[string]string
+		expected      *TestStruct
+		expectError   bool
+	}{
+		{
+			name: "complete substitution",
+			input: &TestStruct{
+				BaseURL:  "https://{{.workspace}}.my.salesforce.com",
+				AuthURL:  "https://{{.workspace}}.my.salesforce.com/services/oauth2/authorize",
+				TokenURL: "https://{{.workspace}}.my.salesforce.com/services/oauth2/token",
+				Nested: NestedStruct{
+					URL: "https://{{.workspace}}.my.salesforce.com/api",
+				},
+				NestedPtr: &NestedStruct{
+					URL: "https://{{.workspace}}.my.salesforce.com/api/v2",
+				},
+				URLMap: map[string]string{
+					"test": "https://{{.workspace}}.my.salesforce.com/test",
+				},
+				NonString: 42,
+			},
+			substitutions: map[string]string{
+				"workspace": "test",
+			},
+			expected: &TestStruct{
+				BaseURL:  "https://test.my.salesforce.com",
+				AuthURL:  "https://test.my.salesforce.com/services/oauth2/authorize",
+				TokenURL: "https://test.my.salesforce.com/services/oauth2/token",
+				Nested: NestedStruct{
+					URL: "https://test.my.salesforce.com/api",
+				},
+				NestedPtr: &NestedStruct{
+					URL: "https://test.my.salesforce.com/api/v2",
+				},
+				URLMap: map[string]string{
+					"test": "https://test.my.salesforce.com/test",
+				},
+				NonString: 42,
+			},
+			expectError: false,
+		},
+		{
+			name: "missing variable",
+			input: &TestStruct{
+				BaseURL: "https://{{.workspace}}.my.salesforce.com",
+			},
+			substitutions: map[string]string{},
+			expected:      nil,
+			expectError:   true,
+		},
+		{
+			name: "no substitutions needed",
+			input: &TestStruct{
+				BaseURL: "https://api.atlassian.com",
+			},
+			substitutions: map[string]string{"workspace": "test"},
+			expected: &TestStruct{
+				BaseURL: "https://api.atlassian.com",
+			},
+			expectError: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := substituteStruct(tt.input, tt.substitutions)
+			if tt.expectError {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+				assert.Equal(t, tt.expected, tt.input)
+			}
+		})
+	}
+}
+
+// TestProviderInfoSubstitution tests substitution with a real ProviderInfo-like structure.
+func TestProviderInfoSubstitution(t *testing.T) {
+	tests := []struct {
+		name          string
+		input         *ProviderInfo
+		substitutions map[string]string
+		expected      *ProviderInfo
+		expectError   bool
+	}{
+		{
+			name:  "provider info substitution",
+			input: &atlassianProviderInfoPreSubstitution,
+			substitutions: map[string]string{
+				"workspace": "test-workspace",
+				"cloudId":   "test-cloud-id",
+				"metadata":  "test-metadata",
+			},
+			expected:    &atlasssianProviderInfoPostSubstitution,
+			expectError: false,
+		},
+		{
+			name:  "salesforce provider info substitution",
+			input: &salesforcePresubstitution,
+			substitutions: map[string]string{
+				"workspace": "test-workspace",
+			},
+			expected:    &salesforcePostSubstitution,
+			expectError: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := substituteStruct(tt.input, tt.substitutions)
+			if tt.expectError {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+				assert.Equal(t, tt.expected, tt.input)
+			}
+		})
+	}
+}
+
+////// =============================================
+////// salesforce & atlassian providerInfo types
+////// =============================================
+
+var (
+	salesforcePresubstitution = ProviderInfo{
+		DisplayName: "Salesforce",
+		AuthType:    Oauth2,
+		BaseURL:     "https://{{.workspace}}.my.salesforce.com",
+		AuthHealthCheck: &AuthHealthCheck{
+			Method:             http.MethodGet,
+			SuccessStatusCodes: []int{http.StatusOK},
+			Url:                "https://{{.workspace}}.my.salesforce.com/services/oauth2/userinfo",
+		},
+		Oauth2Opts: &Oauth2Opts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://{{.workspace}}.my.salesforce.com/services/oauth2/authorize",
+			TokenURL:                  "https://{{.workspace}}.my.salesforce.com/services/oauth2/token",
+			ExplicitScopesRequired:    false,
+			ExplicitWorkspaceRequired: true,
+			TokenMetadataFields: TokenMetadataFields{
+				ConsumerRefField:  "id",
+				WorkspaceRefField: "instance_url",
+				ScopesField:       "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: true,
+				Delete: true,
+			},
+			Proxy:     true,
+			Read:      true,
+			Subscribe: false,
+			Write:     true,
+		},
+		Media: &Media{
+			DarkMode: &MediaTypeDarkMode{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722470590/media/salesforce_1722470589.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722470590/media/salesforce_1722470589.svg",
+			},
+			Regular: &MediaTypeRegular{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722470590/media/salesforce_1722470589.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722470590/media/salesforce_1722470589.svg",
+			},
+		},
+		Metadata: &ProviderMetadata{
+			Input: []MetadataItemInput{
+				{
+					Name:        "workspace",
+					DisplayName: "Subdomain",
+					DocsURL:     "https://help.salesforce.com/s/articleView?language=en_US&id=sf.faq_domain_name_what.htm&type=5",
+				},
+			},
+		},
+	}
+
+	atlassianProviderInfoPreSubstitution = ProviderInfo{
+		DisplayName: "Atlassian",
+		AuthType:    Oauth2,
+		BaseURL:     "https://api.{{.metadata}}.atlassian.com",
+		Oauth2Opts: &Oauth2Opts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://auth.atlassian.com/authorize",
+			TokenURL:                  "https://auth.atlassian.com/oauth/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: true, // Needed for GetPostAuthInfo call
+		},
+		PostAuthInfoNeeded: true,
+		DefaultModule:      ModuleAtlassianJira,
+		Modules: Modules{
+			ModuleAtlassianJira: {
+				BaseURL:     "https://api.atlassian.com/ex/jira/{{.cloudId}}/rest/api/3",
+				DisplayName: "Atlassian Jira",
+				Support: Support{
+					Read:      true,
+					Subscribe: false,
+					Write:     true,
+				},
+			},
+			ModuleAtlassianJiraConnect: {
+				BaseURL:     "https://{{.workspace}}.atlassian.net/rest/api/3",
+				DisplayName: "Atlassian Connect",
+				Support: Support{
+					Read:      true,
+					Subscribe: false,
+					Write:     true,
+				},
+			},
+		},
+		//nolint:lll
+		Media: &Media{
+			DarkMode: &MediaTypeDarkMode{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722490152/media/const%20Atlassian%20Provider%20%3D%20%22atlassian%22_1722490153.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722490205/media/const%20Atlassian%20Provider%20%3D%20%22atlassian%22_1722490206.svg",
+			},
+			Regular: &MediaTypeRegular{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722490152/media/const%20Atlassian%20Provider%20%3D%20%22atlassian%22_1722490153.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722490205/media/const%20Atlassian%20Provider%20%3D%20%22atlassian%22_1722490206.svg",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     true,
+			Read:      true,
+			Subscribe: false,
+			Write:     true,
+		},
+		Metadata: &ProviderMetadata{
+			PostAuthentication: []MetadataItemPostAuthentication{
+				{
+					Name: "cloudId",
+				},
+			},
+			Input: []MetadataItemInput{
+				{
+					Name:        "workspace",
+					DisplayName: "App name",
+					DocsURL:     "https://support.atlassian.com/organization-administration/docs/update-your-product-and-site-url/",
+				},
+			},
+		},
+	}
+)
+
+var (
+	salesforcePostSubstitution = ProviderInfo{
+		DisplayName: "Salesforce",
+		AuthType:    Oauth2,
+		BaseURL:     "https://test-workspace.my.salesforce.com",
+		AuthHealthCheck: &AuthHealthCheck{
+			Method:             http.MethodGet,
+			SuccessStatusCodes: []int{http.StatusOK},
+			Url:                "https://test-workspace.my.salesforce.com/services/oauth2/userinfo",
+		},
+		Oauth2Opts: &Oauth2Opts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://test-workspace.my.salesforce.com/services/oauth2/authorize",
+			TokenURL:                  "https://test-workspace.my.salesforce.com/services/oauth2/token",
+			ExplicitScopesRequired:    false,
+			ExplicitWorkspaceRequired: true,
+			TokenMetadataFields: TokenMetadataFields{
+				ConsumerRefField:  "id",
+				WorkspaceRefField: "instance_url",
+				ScopesField:       "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: true,
+				Delete: true,
+			},
+			Proxy:     true,
+			Read:      true,
+			Subscribe: false,
+			Write:     true,
+		},
+		Media: &Media{
+			DarkMode: &MediaTypeDarkMode{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722470590/media/salesforce_1722470589.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722470590/media/salesforce_1722470589.svg",
+			},
+			Regular: &MediaTypeRegular{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722470590/media/salesforce_1722470589.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722470590/media/salesforce_1722470589.svg",
+			},
+		},
+		Metadata: &ProviderMetadata{
+			Input: []MetadataItemInput{
+				{
+					Name:        "workspace",
+					DisplayName: "Subdomain",
+					DocsURL:     "https://help.salesforce.com/s/articleView?language=en_US&id=sf.faq_domain_name_what.htm&type=5",
+				},
+			},
+		},
+	}
+
+	atlasssianProviderInfoPostSubstitution = ProviderInfo{
+		DisplayName: "Atlassian",
+		AuthType:    Oauth2,
+		BaseURL:     "https://api.test-metadata.atlassian.com",
+		Oauth2Opts: &Oauth2Opts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://auth.atlassian.com/authorize",
+			TokenURL:                  "https://auth.atlassian.com/oauth/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: true, // Needed for GetPostAuthInfo call
+		},
+		PostAuthInfoNeeded: true,
+		DefaultModule:      ModuleAtlassianJira,
+		Modules: Modules{
+			ModuleAtlassianJira: {
+				BaseURL:     "https://api.atlassian.com/ex/jira/test-cloud-id/rest/api/3",
+				DisplayName: "Atlassian Jira",
+				Support: Support{
+					Read:      true,
+					Subscribe: false,
+					Write:     true,
+				},
+			},
+			ModuleAtlassianJiraConnect: {
+				BaseURL:     "https://test-workspace.atlassian.net/rest/api/3",
+				DisplayName: "Atlassian Connect",
+				Support: Support{
+					Read:      true,
+					Subscribe: false,
+					Write:     true,
+				},
+			},
+		},
+		//nolint:lll
+		Media: &Media{
+			DarkMode: &MediaTypeDarkMode{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722490152/media/const%20Atlassian%20Provider%20%3D%20%22atlassian%22_1722490153.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722490205/media/const%20Atlassian%20Provider%20%3D%20%22atlassian%22_1722490206.svg",
+			},
+			Regular: &MediaTypeRegular{
+				IconURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722490152/media/const%20Atlassian%20Provider%20%3D%20%22atlassian%22_1722490153.svg",
+				LogoURL: "https://res.cloudinary.com/dycvts6vp/image/upload/v1722490205/media/const%20Atlassian%20Provider%20%3D%20%22atlassian%22_1722490206.svg",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     true,
+			Read:      true,
+			Subscribe: false,
+			Write:     true,
+		},
+		Metadata: &ProviderMetadata{
+			PostAuthentication: []MetadataItemPostAuthentication{
+				{
+					Name: "cloudId",
+				},
+			},
+			Input: []MetadataItemInput{
+				{
+					Name:        "workspace",
+					DisplayName: "App name",
+					DocsURL:     "https://support.atlassian.com/organization-administration/docs/update-your-product-and-site-url/",
+				},
+			},
+		},
+	}
+)
+
+////// =============================================
+////// copied from providers/types.gen.go
+////// =============================================
+
+// Defines values for ApiKeyAsBasicOptsFieldUsed.
+const (
+	PasswordField ApiKeyAsBasicOptsFieldUsed = "password"
+	UsernameField ApiKeyAsBasicOptsFieldUsed = "username"
+
+	ModuleAtlassianJira        common.ModuleID = "atlassianJira"
+	ModuleAtlassianJiraConnect common.ModuleID = "atlassianJiraConnect"
+)
+
+// Defines values for ApiKeyOptsAttachmentType.
+const (
+	Header ApiKeyOptsAttachmentType = "header"
+	Query  ApiKeyOptsAttachmentType = "query"
+)
+
+// Defines values for AuthType.
+const (
+	ApiKey AuthType = "apiKey"
+	Basic  AuthType = "basic"
+	Jwt    AuthType = "jwt"
+	None   AuthType = "none"
+	Oauth2 AuthType = "oauth2"
+)
+
+// Defines values for Oauth2OptsGrantType.
+const (
+	AuthorizationCode     Oauth2OptsGrantType = "authorizationCode"
+	AuthorizationCodePKCE Oauth2OptsGrantType = "authorizationCodePKCE"
+	ClientCredentials     Oauth2OptsGrantType = "clientCredentials"
+	Password              Oauth2OptsGrantType = "password"
+)
+
+// Defines values for SubscribeOptsRegistrationTiming.
+const (
+	SubscribeOptsRegistrationTimingInstallation SubscribeOptsRegistrationTiming = "installation"
+	SubscribeOptsRegistrationTimingIntegration  SubscribeOptsRegistrationTiming = "integration"
+	SubscribeOptsRegistrationTimingProviderApp  SubscribeOptsRegistrationTiming = "providerApp"
+)
+
+// Defines values for SubscribeOptsSubscriptionScope.
+const (
+	SubscribeOptsSubscriptionScopeInstallation SubscribeOptsSubscriptionScope = "installation"
+	SubscribeOptsSubscriptionScopeIntegration  SubscribeOptsSubscriptionScope = "integration"
+)
+
+// ApiKeyAsBasicOpts when this object is present, it means that this provider uses Basic Auth to actually collect an API key.
+type ApiKeyAsBasicOpts struct {
+	// FieldUsed whether the API key should be used as the username or password.
+	FieldUsed ApiKeyAsBasicOptsFieldUsed `json:"fieldUsed,omitempty"`
+
+	// KeyFormat How to transform the API key in to a basic auth user:pass string. The %s is replaced with the API key value.
+	KeyFormat string `json:"keyFormat,omitempty"`
+}
+
+// ApiKeyAsBasicOptsFieldUsed whether the API key should be used as the username or password.
+type ApiKeyAsBasicOptsFieldUsed string
+
+// ApiKeyOpts Configuration for API key. Must be provided if authType is apiKey.
+type ApiKeyOpts struct {
+	// AttachmentType How the API key should be attached to requests.
+	AttachmentType ApiKeyOptsAttachmentType `json:"attachmentType" validate:"required"`
+
+	// DocsURL URL with more information about how to get or use an API key.
+	DocsURL string `json:"docsURL,omitempty"`
+
+	// Header Configuration for API key in header. Must be provided if type is in-header.
+	Header *ApiKeyOptsHeader `json:"header,omitempty"`
+
+	// Query Configuration for API key in query parameter. Must be provided if type is in-query.
+	Query *ApiKeyOptsQuery `json:"query,omitempty"`
+}
+
+// ApiKeyOptsAttachmentType How the API key should be attached to requests.
+type ApiKeyOptsAttachmentType string
+
+// ApiKeyOptsHeader Configuration for API key in header. Must be provided if type is in-header.
+type ApiKeyOptsHeader struct {
+	// Name The name of the header to be used for the API key.
+	Name string `json:"name"`
+
+	// ValuePrefix The prefix to be added to the API key value when it is sent in the header.
+	ValuePrefix string `json:"valuePrefix,omitempty"`
+}
+
+// ApiKeyOptsQuery Configuration for API key in query parameter. Must be provided if type is in-query.
+type ApiKeyOptsQuery struct {
+	// Name The name of the query parameter to be used for the API key.
+	Name string `json:"name"`
+}
+
+// AuthHealthCheck A URL to check the health of a provider's credentials. It's used to see if the credentials are valid and if the provider is reachable.
+type AuthHealthCheck struct {
+	// Method The HTTP method to use for the health check. If not set, defaults to GET.
+	Method string `json:"method,omitempty"`
+
+	// SuccessStatusCodes The HTTP status codes that indicate a successful health check. If not set, defaults to 200 and 204.
+	SuccessStatusCodes []int `json:"successStatusCodes,omitempty"`
+
+	// Url a no-op URL to check the health of the credentials. The URL MUST not mutate any state. If the provider doesn't have such an endpoint, then don't provide credentialsHealthCheck.
+	Url string `json:"url"`
+}
+
+// AuthType The type of authentication required by the provider.
+type AuthType string
+
+// BasicAuthOpts Configuration for Basic Auth. Optional.
+type BasicAuthOpts struct {
+	// ApiKeyAsBasic If true, the provider uses an API key which then gets encoded as a basic auth user:pass string.
+	ApiKeyAsBasic bool `json:"apiKeyAsBasic,omitempty"`
+
+	// ApiKeyAsBasicOpts when this object is present, it means that this provider uses Basic Auth to actually collect an API key
+	ApiKeyAsBasicOpts *ApiKeyAsBasicOpts `json:"apiKeyAsBasicOpts,omitempty"`
+
+	// DocsURL URL with more information about how to get or use an API key.
+	DocsURL string `json:"docsURL,omitempty"`
+}
+
+// BulkWriteSupport defines model for BulkWriteSupport.
+type BulkWriteSupport struct {
+	Delete bool `json:"delete"`
+	Insert bool `json:"insert"`
+	Update bool `json:"update"`
+	Upsert bool `json:"upsert"`
+}
+
+// CatalogType defines model for CatalogType.
+type CatalogType map[string]ProviderInfo
+
+// CatalogWrapper defines model for CatalogWrapper.
+type CatalogWrapper struct {
+	Catalog CatalogType `json:"catalog"`
+
+	// Timestamp An RFC3339 formatted timestamp of when the catalog was generated.
+	Timestamp string `json:"timestamp" validate:"required"`
+}
+
+// Labels defines model for Labels.
+type Labels map[string]string
+
+// Media defines model for Media.
+type Media struct {
+	// DarkMode Media to be used in dark mode.
+	DarkMode *MediaTypeDarkMode `json:"darkMode,omitempty"`
+
+	// Regular Media for light/regular mode.
+	Regular *MediaTypeRegular `json:"regular,omitempty"`
+}
+
+// MediaTypeDarkMode Media to be used in dark mode.
+type MediaTypeDarkMode struct {
+	// IconURL URL to the icon for the provider that is to be used in dark mode.
+	IconURL string `json:"iconURL,omitempty"`
+
+	// LogoURL URL to the logo for the provider that is to be used in dark mode.
+	LogoURL string `json:"logoURL,omitempty"`
+}
+
+// MediaTypeRegular Media for light/regular mode.
+type MediaTypeRegular struct {
+	// IconURL URL to the icon for the provider.
+	IconURL string `json:"iconURL,omitempty"`
+
+	// LogoURL URL to the logo for the provider.
+	LogoURL string `json:"logoURL,omitempty"`
+}
+
+// MetadataItemInput defines model for MetadataItemInput.
+type MetadataItemInput struct {
+	// DisplayName The human-readable name for the field
+	DisplayName string `json:"displayName,omitempty"`
+
+	// DocsURL URL with more information about how to locate this value
+	DocsURL string `json:"docsURL,omitempty"`
+
+	// Name The internal identifier for the metadata field
+	Name string `json:"name"`
+}
+
+// MetadataItemPostAuthentication defines model for MetadataItemPostAuthentication.
+type MetadataItemPostAuthentication struct {
+	// Name The internal identifier for the metadata field
+	Name string `json:"name"`
+}
+
+// ModuleInfo defines model for ModuleInfo.
+type ModuleInfo struct {
+	BaseURL     string `json:"baseURL"`
+	DisplayName string `json:"displayName"`
+
+	// Support The supported features for the provider.
+	Support Support `json:"support" validate:"required"`
+}
+
+// Modules The registry of provider modules.
+type Modules = map[common.ModuleID]ModuleInfo
+
+// Oauth2Opts Configuration for OAuth2.0. Must be provided if authType is oauth2.
+type Oauth2Opts struct {
+	// Audience A list of URLs that represent the audience for the token, which is needed for some client credential grant flows.
+	Audience []string `json:"audience,omitempty"`
+
+	// AuthURL The authorization URL.
+	AuthURL       string            `json:"authURL,omitempty"`
+	AuthURLParams map[string]string `json:"authURLParams,omitempty"`
+
+	// DocsURL URL with more information about where to retrieve Client ID and Client Secret, etc.
+	DocsURL string `json:"docsURL,omitempty"`
+
+	// ExplicitScopesRequired Whether scopes are required to be known ahead of the OAuth flow.
+	ExplicitScopesRequired bool `json:"explicitScopesRequired"`
+
+	// ExplicitWorkspaceRequired Whether the workspace is required to be known ahead of the OAuth flow.
+	ExplicitWorkspaceRequired bool                `json:"explicitWorkspaceRequired"`
+	GrantType                 Oauth2OptsGrantType `json:"grantType"`
+
+	// TokenMetadataFields Fields to be used to extract token metadata from the token response.
+	TokenMetadataFields TokenMetadataFields `json:"tokenMetadataFields"`
+
+	// TokenURL The token URL.
+	TokenURL string `json:"tokenURL" validate:"required"`
+}
+
+// Oauth2OptsGrantType defines model for Oauth2Opts.GrantType.
+type Oauth2OptsGrantType string
+
+// Provider defines model for Provider.
+type Provider = string
+
+// ProviderInfo defines model for ProviderInfo.
+type ProviderInfo struct {
+	// ApiKeyOpts Configuration for API key. Must be provided if authType is apiKey.
+	ApiKeyOpts *ApiKeyOpts `json:"apiKeyOpts,omitempty"`
+
+	// AuthHealthCheck A URL to check the health of a provider's credentials. It's used to see if the credentials are valid and if the provider is reachable.
+	AuthHealthCheck *AuthHealthCheck `json:"authHealthCheck,omitempty"`
+
+	// AuthType The type of authentication required by the provider.
+	AuthType AuthType `json:"authType" validate:"required"`
+
+	// BaseURL The base URL for making API requests.
+	BaseURL string `json:"baseURL" validate:"required"`
+
+	// BasicOpts Configuration for Basic Auth. Optional.
+	BasicOpts     *BasicAuthOpts  `json:"basicOpts,omitempty"`
+	DefaultModule common.ModuleID `json:"defaultModule"`
+
+	// DisplayName The display name of the provider, if omitted, defaults to provider name.
+	DisplayName string  `json:"displayName,omitempty"`
+	Labels      *Labels `json:"labels,omitempty"`
+	Media       *Media  `json:"media,omitempty"`
+
+	// Metadata Provider metadata that needs to be given by the user or fetched by the connector post authentication for the connector to work.
+	Metadata *ProviderMetadata `json:"metadata,omitempty"`
+
+	// Modules The registry of provider modules.
+	Modules Modules `json:"modules,omitempty"`
+	Name    string  `json:"name"`
+
+	// Oauth2Opts Configuration for OAuth2.0. Must be provided if authType is oauth2.
+	Oauth2Opts *Oauth2Opts `json:"oauth2Opts,omitempty"`
+
+	// PostAuthInfoNeeded If true, we require additional information after auth to start making requests.
+	PostAuthInfoNeeded bool `json:"postAuthInfoNeeded,omitempty"`
+
+	// ProviderOpts Additional provider-specific metadata.
+	ProviderOpts  ProviderOpts   `json:"providerOpts"`
+	SubscribeOpts *SubscribeOpts `json:"subscribeOpts,omitempty"`
+
+	// Support The supported features for the provider.
+	Support Support `json:"support" validate:"required"`
+}
+
+// ProviderMetadata Provider metadata that needs to be given by the user or fetched by the connector post authentication for the connector to work.
+type ProviderMetadata struct {
+	// Input Metadata provided as manual input
+	Input []MetadataItemInput `json:"input,omitempty"`
+
+	// PostAuthentication Metadata fetched by the connector post authentication
+	PostAuthentication []MetadataItemPostAuthentication `json:"postAuthentication,omitempty"`
+}
+
+// ProviderOpts Additional provider-specific metadata.
+type ProviderOpts map[string]string
+
+// SubscribeOpts defines model for SubscribeOpts.
+type SubscribeOpts struct {
+	// RegistrationTiming The timing of the registration.
+	RegistrationTiming SubscribeOptsRegistrationTiming `json:"registrationTiming"`
+
+	// SubscriptionScope The scope of the subscription.
+	SubscriptionScope SubscribeOptsSubscriptionScope `json:"subscriptionScope"`
+
+	// TargetURLScope The scope of the target URL.
+	TargetURLScope interface{} `json:"targetURLScope"`
+}
+
+// SubscribeOptsRegistrationTiming The timing of the registration.
+type SubscribeOptsRegistrationTiming string
+
+// SubscribeOptsSubscriptionScope The scope of the subscription.
+type SubscribeOptsSubscriptionScope string
+
+// SubscribeSupport defines model for SubscribeSupport.
+type SubscribeSupport struct {
+	Create      *bool `json:"create,omitempty"`
+	Delete      *bool `json:"delete,omitempty"`
+	PassThrough *bool `json:"passThrough,omitempty"`
+	Update      *bool `json:"update,omitempty"`
+}
+
+// Support The supported features for the provider.
+type Support struct {
+	BulkWrite        BulkWriteSupport  `json:"bulkWrite"                  validate:"required"`
+	Proxy            bool              `json:"proxy"`
+	Read             bool              `json:"read"`
+	Subscribe        bool              `json:"subscribe"`
+	SubscribeSupport *SubscribeSupport `json:"subscribeSupport,omitempty"`
+	Write            bool              `json:"write"`
+}
+
+// TokenMetadataFields Fields to be used to extract token metadata from the token response.
+type TokenMetadataFields struct {
+	ConsumerRefField string `json:"consumerRefField,omitempty"`
+
+	// OtherFields Additional fields to extract and transform from the token response
+	OtherFields       *TokenMetadataFieldsOtherFields `json:"otherFields,omitempty"`
+	ScopesField       string                          `json:"scopesField,omitempty"`
+	WorkspaceRefField string                          `json:"workspaceRefField,omitempty"`
+}
+
+// TokenMetadataFieldsOtherFields Additional fields to extract and transform from the token response.
+type TokenMetadataFieldsOtherFields = []struct {
+	// Capture A regex expression to capture the value that we need from the path. There must be only one capture group named 'result' in the expression. If not provided, will cause an error.
+	Capture string `json:"capture,omitempty"`
+
+	// DisplayName The human-readable name of the field
+	DisplayName string `json:"displayName"`
+
+	// Name The internal name of the field
+	Name string `json:"name"`
+
+	// Path The path to the field in the token response (accepts dot notation for nested fields)
+	Path string `json:"path"`
+}
diff --git a/providers/utils_test.go b/providers/utils_test.go
index bbcb315a..adef66a9 100644
--- a/providers/utils_test.go
+++ b/providers/utils_test.go
@@ -451,7 +451,7 @@ func TestReadModuleInfo(t *testing.T) { // nolint:funlen,maintidx
 				moduleID: "random-module-name",
 			},
 			expected: &ModuleInfo{
-				BaseURL:     "https://{{.workspace}}.mktorest.com/v1",
+				BaseURL:     "https://london.mktorest.com/v1",
 				DisplayName: "Marketo (Leads)",
 				Support: Support{
 					Read:  true,
@@ -468,7 +468,7 @@ func TestReadModuleInfo(t *testing.T) { // nolint:funlen,maintidx
 				moduleID: "random-module-name",
 			},
 			expected: &ModuleInfo{
-				BaseURL:     "https://{{.workspace}}.zendesk.com/api/v2",
+				BaseURL:     "https://london.zendesk.com/api/v2",
 				DisplayName: "Zendesk Ticketing",
 				Support: Support{
 					Read:  true,
@@ -724,7 +724,7 @@ func TestReadModuleInfo(t *testing.T) { // nolint:funlen,maintidx
 				moduleID: "",
 			},
 			expected: &ModuleInfo{
-				BaseURL:     "https://{{.workspace}}.mktorest.com/v1",
+				BaseURL:     "https://london.mktorest.com/v1",
 				DisplayName: "Marketo (Leads)",
 				Support: Support{
 					Read:  true,