Repository: amp-labs/connectors
Pull request #1083 (2024-10-16 23:02:02)

feat: OpenAPI tool extracts POST operations
# Description

This is an improvement to OpenAPI schema extractor tool.
Now it supports querying non-GET methods. In particular we are interested in POST which act as search in most APIs.


# Usage

Later dependent intercom PR benefits by adding `tickets` to metadata and Read can be done by POST. It wasn't present already because, there is no GET endpoint.

Cobalt0s: * **#1082** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1082?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1084** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1084?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1083** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1083?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈
* `main`

This stack of pull requests is managed by Graphite. <a href="https://stacking.dev/?utm_source=stack-comment">Learn more about stacking.</a>
<h2></h2>

 Join @Cobalt0s and the rest of your teammates on <a href="https://graphite.dev?utm-source=stack-comment"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="11px" height="11px"/> <b>Graphite</b></a>
  <!-- Current dependencies on/for this PR: -->
Cobalt0s: * **#1082** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1082?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1084** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1084?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1083** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1083?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈
* `main`

This stack of pull requests is managed by Graphite. <a href="https://stacking.dev/?utm_source=stack-comment">Learn more about stacking.</a>
<h2></h2>

 Join @Cobalt0s and the rest of your teammates on <a href="https://graphite.dev?utm-source=stack-comment"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="11px" height="11px"/> <b>Graphite</b></a>
  <!-- Current dependencies on/for this PR: -->

Patch:
diff --git a/go.mod b/go.mod
index f337e6a8..c51b04d0 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,7 @@ require (
 	github.com/iancoleman/strcase v0.3.0
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/viper v1.19.0
-	github.com/spyzhov/ajson v0.9.4
+	github.com/spyzhov/ajson v0.9.5
 	github.com/stretchr/testify v1.9.0
 	golang.org/x/oauth2 v0.23.0
 	golang.org/x/text v0.19.0
diff --git a/go.sum b/go.sum
index b1b19034..7bc9b234 100644
--- a/go.sum
+++ b/go.sum
@@ -87,8 +87,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
 github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
-github.com/spyzhov/ajson v0.9.4 h1:MVibcTCgO7DY4IlskdqIlCmDOsUOZ9P7oKj8ifdcf84=
-github.com/spyzhov/ajson v0.9.4/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8=
+github.com/spyzhov/ajson v0.9.5 h1:/W2YIkLtwQ0W01qABeX89sXK1AV4+bUhc/3uLj3MToo=
+github.com/spyzhov/ajson v0.9.5/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
diff --git a/scripts/openapi/gong/metadata/main.go b/scripts/openapi/gong/metadata/main.go
index c36bea61..a53d9141 100644
--- a/scripts/openapi/gong/metadata/main.go
+++ b/scripts/openapi/gong/metadata/main.go
@@ -61,8 +61,9 @@ func main() {
 	)
 	must(err)
 
-	objects, err := explorer.GetBasicReadObjects(
-		ignoreEndpoints, nil, nil, api3.IdenticalObjectCheck,
+	objects, err := explorer.ReadObjectsGet(
+		api3.NewDenyPathStrategy(ignoreEndpoints),
+		nil, nil, api3.IdenticalObjectCheck,
 	)
 	must(err)
 
diff --git a/scripts/openapi/intercom/metadata/main.go b/scripts/openapi/intercom/metadata/main.go
index 6e62e8af..3a995225 100644
--- a/scripts/openapi/intercom/metadata/main.go
+++ b/scripts/openapi/intercom/metadata/main.go
@@ -40,8 +40,9 @@ func main() {
 	explorer, err := openapi.FileManager.GetExplorer()
 	must(err)
 
-	objects, err := explorer.GetBasicReadObjects(
-		ignoreEndpoints, nil, displayNameOverride,
+	objects, err := explorer.ReadObjectsGet(
+		api3.NewDenyPathStrategy(ignoreEndpoints),
+		nil, displayNameOverride,
 		api3.CustomMappingObjectCheck(intercom.ObjectNameToResponseField),
 	)
 	must(err)
diff --git a/scripts/openapi/pipedrive/metadata/main.go b/scripts/openapi/pipedrive/metadata/main.go
index fdbed7c5..0a0b46e9 100644
--- a/scripts/openapi/pipedrive/metadata/main.go
+++ b/scripts/openapi/pipedrive/metadata/main.go
@@ -66,8 +66,9 @@ func main() {
 		log.Fatalln(err)
 	}
 
-	objects, err := explorer.GetBasicReadObjects(
-		ignoreEndpoints, nil, displayName,
+	objects, err := explorer.ReadObjectsGet(
+		api3.NewDenyPathStrategy(ignoreEndpoints),
+		nil, displayName,
 		api3.DataObjectCheck)
 	if err != nil {
 		log.Fatalln(err)
diff --git a/scripts/openapi/pipeliner/metadata/main.go b/scripts/openapi/pipeliner/metadata/main.go
index d1324f93..04e0938b 100644
--- a/scripts/openapi/pipeliner/metadata/main.go
+++ b/scripts/openapi/pipeliner/metadata/main.go
@@ -37,8 +37,9 @@ func main() {
 		))
 	must(err)
 
-	objects, err := explorer.GetBasicReadObjects(
-		ignoreEndpoints, nil, displayNameOverride, api3.DataObjectCheck,
+	objects, err := explorer.ReadObjectsGet(
+		api3.NewDenyPathStrategy(ignoreEndpoints),
+		nil, displayNameOverride, api3.DataObjectCheck,
 	)
 	must(err)
 
diff --git a/scripts/openapi/zendesksupport/metadata/main.go b/scripts/openapi/zendesksupport/metadata/main.go
index b65b8be6..96008db6 100644
--- a/scripts/openapi/zendesksupport/metadata/main.go
+++ b/scripts/openapi/zendesksupport/metadata/main.go
@@ -80,8 +80,9 @@ func main() {
 	)
 	must(err)
 
-	objects, err := explorer.GetBasicReadObjects(
-		ignoreEndpoints, nil, displayNameOverride,
+	objects, err := explorer.ReadObjectsGet(
+		api3.NewDenyPathStrategy(ignoreEndpoints),
+		nil, displayNameOverride,
 		api3.CustomMappingObjectCheck(zendesksupport.ObjectNameToResponseField),
 	)
 	must(err)
diff --git a/test/utils/mockutils/mockserver/crossroads.go b/test/utils/mockutils/mockserver/switch.go
similarity index 100%
rename from test/utils/mockutils/mockserver/crossroads.go
rename to test/utils/mockutils/mockserver/switch.go
diff --git a/tools/fileconv/api3/models.go b/tools/fileconv/api3/models.go
index 0d11da1e..861fdf3f 100644
--- a/tools/fileconv/api3/models.go
+++ b/tools/fileconv/api3/models.go
@@ -41,6 +41,21 @@ type Schema struct {
 	Problem     error
 }
 
+type Schemas []Schema
+
+func (s Schemas) Combine(others Schemas) Schemas {
+	registry := handy.Map[string, Schema]{}
+	for _, schema := range append(s, others...) {
+		_, found := registry[schema.ObjectName]
+
+		if !found || len(schema.Fields) != 0 {
+			registry[schema.ObjectName] = schema
+		}
+	}
+
+	return registry.Values()
+}
+
 func (s Schema) String() string {
 	if s.Problem != nil {
 		return fmt.Sprintf("    {%v}    ", s.ObjectName)
@@ -49,11 +64,12 @@ func (s Schema) String() string {
 	return fmt.Sprintf("%v=[%v]", s.ObjectName, strings.Join(s.Fields, ","))
 }
 
-func (p PathItem) RetrieveSchemaOperationGet(
+func (p PathItem) RetrieveSchemaOperation(
+	operationName string,
 	displayNameOverride map[string]string, check ObjectCheck, displayProcessor DisplayNameProcessor,
 	parameterFilter ParameterFilterGetMethod,
 ) (*Schema, bool, error) {
-	operation := p.delegate.Get
+	operation := p.selectOperation(operationName)
 	if operation == nil {
 		return nil, false, nil
 	}
@@ -95,6 +111,19 @@ func (p PathItem) RetrieveSchemaOperationGet(
 	}, true, nil
 }
 
+func (p PathItem) selectOperation(operationName string) *openapi3.Operation {
+	switch operationName {
+	case "POST":
+		return p.delegate.Post
+	case "PUT":
+		return p.delegate.Put
+	case "PATCH":
+		return p.delegate.Patch
+	default:
+		return p.delegate.Get
+	}
+}
+
 func extractFieldsFromArrayItem(objectName string, schema *openapi3.Schema, check ObjectCheck) ([]string, error) {
 	checkExpectationsOperationResponseSchema(schema)
 
diff --git a/tools/fileconv/api3/queries.go b/tools/fileconv/api3/queries.go
index 6240c42b..b8824005 100644
--- a/tools/fileconv/api3/queries.go
+++ b/tools/fileconv/api3/queries.go
@@ -15,20 +15,56 @@ type Explorer struct {
 	*parameters
 }
 
-// GetBasicReadObjects retrieves schemas that can be used by ListObjectMetadata.
-// objectEndpoints - optional map of Endpoint Path to ObjectName associated with it.
-// ignoreEndpoints - optional list of paths to ignore
-// displayNameOverride - optional map of ObjectName to DisplayName. This will override display name from OpenAPI doc.
-func (e Explorer) GetBasicReadObjects(
-	ignoreEndpoints []string,
+// ReadObjectsGet is the same as ReadObjectsGet but retrieves schemas for endpoints that perform reading via GET.
+// If you need schemas located under GET and POST operations,
+// make 2 calls as they will have different arguments in particular PathMatchingStrategy,
+// and then Combine two lists of schemas.
+func (e Explorer) ReadObjectsGet(
+	pathMatcher PathMatcher,
 	objectEndpoints map[string]string,
 	displayNameOverride map[string]string,
 	check ObjectCheck,
-) ([]Schema, error) {
-	schemas := make([]Schema, 0)
+) (Schemas, error) {
+	return e.ReadObjects("GET", pathMatcher, objectEndpoints, displayNameOverride, check)
+}
+
+// ReadObjectsPost is the same as ReadObjectsGet but retrieves schemas for endpoints that perform reading via POST.
+func (e Explorer) ReadObjectsPost(
+	pathMatcher PathMatcher,
+	objectEndpoints map[string]string,
+	displayNameOverride map[string]string,
+	check ObjectCheck,
+) (Schemas, error) {
+	return e.ReadObjects("POST", pathMatcher, objectEndpoints, displayNameOverride, check)
+}
+
+// ReadObjects will explore OpenAPI file returning list of Schemas.
+// See every parameter for detailed customization.
+//
+// operationName - under which REST operation the schema resides. Ex: GET - list reading, POST - search reading.
+// pathMatcher - guides which URL paths to include in search or to ignore.
+// objectEndpoints - URL path mapped to ObjectName.
+// Ex: 	/customer/orders -> orders.
+//
+//	Note: deep connector would need to do the reverse mapping to reconstruct URL given orders objectName.
+//
+// displayNameOverride - objectName mapped to custom Display name.
+// check - callback that returns true if fieldName matched the target location of Object in response.
+// Ex: 	if (objectName == orders && fieldName == data) => true
+//
+//	Given response with fields {meta{}, data{}, pagination{}} for orders object,
+//	the implementation indicates that schema will be located under `data`.
+func (e Explorer) ReadObjects(
+	operationName string,
+	pathMatcher PathMatcher,
+	objectEndpoints map[string]string,
+	displayNameOverride map[string]string,
+	check ObjectCheck,
+) (Schemas, error) {
+	schemas := make(Schemas, 0)
 
-	for _, path := range e.GetBasicPathItems(newIgnorePathStrategy(ignoreEndpoints), objectEndpoints) {
-		schema, found, err := path.RetrieveSchemaOperationGet(
+	for _, path := range e.GetPathItems(pathMatcher, objectEndpoints) {
+		schema, found, err := path.RetrieveSchemaOperation(operationName,
 			displayNameOverride, check, e.displayPostProcessing, e.parameterFilter,
 		)
 		if err != nil {
@@ -48,14 +84,14 @@ func (e Explorer) GetBasicReadObjects(
 	return schemas, nil
 }
 
-// GetBasicPathItems returns path items where object name is a single word.
-func (e Explorer) GetBasicPathItems(
-	ignoreEndpoints *ignorePathStrategy, endpointResources map[string]string,
+// GetPathItems returns path items where object name is a single word.
+func (e Explorer) GetPathItems(
+	pathMatcher PathMatcher, endpointResources map[string]string,
 ) []PathItem {
 	items := handy.Map[string, PathItem]{}
 
 	for path, pathObj := range e.schema.GetPaths() {
-		if ignoreEndpoints.Check(path) {
+		if !pathMatcher.IsPathMatching(path) {
 			// Ignore this endpoint path.
 			continue
 		}
diff --git a/tools/fileconv/api3/strategy.go b/tools/fileconv/api3/strategy.go
index 0beffdcc..d2e39a84 100644
--- a/tools/fileconv/api3/strategy.go
+++ b/tools/fileconv/api3/strategy.go
@@ -6,23 +6,59 @@ import (
 	"github.com/amp-labs/connectors/common/handy"
 )
 
-// This strategy prunes URL Path that should be omitted during Schema extraction.
-// It allows hard coded endpoint Path, as well as simple star rules focusing on matching prefix or suffix.
+// NewAllowPathStrategy produces a path matching strategy that will accept only those paths that matched the list.
+// Others will be denied.
+// You can use star symbol to create a wild matcher.
 // Ex:
-// Basic:	/v1/orders	- ignores this path
-// Suffix:	*/batch		- ignores paths ending with batch
-// Prefix:	/v2/*		- ignores paths starting with v2.
-type ignorePathStrategy struct {
-	ignoreEndpoints handy.StringSet
-	prefixes        []string
-	suffixes        []string
+// Basic:	/v1/orders	- matches exact path
+// Suffix:	*/batch		- matches paths ending with batch
+// Prefix:	/v2/*		- matches paths starting with v2.
+func NewAllowPathStrategy(paths []string) *StarRulePathResolver {
+	return newStarRulePathResolver(paths, func(matched bool) bool {
+		return matched
+	})
 }
 
-func newIgnorePathStrategy(endpoints []string) *ignorePathStrategy {
-	result := &ignorePathStrategy{
-		ignoreEndpoints: handy.NewStringSet(),
-		prefixes:        make([]string, 0),
-		suffixes:        make([]string, 0),
+// NewDenyPathStrategy produces a path matching strategy that will deny only those paths that matched the list.
+// Others will be allowed.
+// You can use star symbol to create a wild matcher.
+// Ex:
+// Basic:	/v1/orders	- deny exact path
+// Suffix:	*/batch		- deny paths ending with batch
+// Prefix:	/v2/*		- deny paths starting with v2.
+func NewDenyPathStrategy(paths []string) *StarRulePathResolver {
+	return newStarRulePathResolver(paths, func(matched bool) bool {
+		// if matched, deny instead.
+		return !matched
+	})
+}
+
+type PathMatcher interface {
+	IsPathMatching(path string) bool
+}
+
+// StarRulePathResolver will report if path matches endpoint rule.
+// Match can occur in 3 different ways,
+// * exact value is inside the registry
+// * or using star rule for
+//   - prefix matching,
+//   - suffix matching.
+type StarRulePathResolver struct {
+	endpoints            handy.StringSet
+	prefixes             []string
+	suffixes             []string
+	pathMatchingCallback func(hasMatched bool) bool
+}
+
+func newStarRulePathResolver(
+	endpoints []string,
+	pathMatchingCallback func(matched bool) bool,
+) *StarRulePathResolver {
+	result := &StarRulePathResolver{
+		endpoints:            handy.NewStringSet(),
+		prefixes:             make([]string, 0),
+		suffixes:             make([]string, 0),
+		pathMatchingCallback: pathMatchingCallback,
 	}
 
 	for _, endpoint := range endpoints {
@@ -31,30 +67,29 @@ func newIgnorePathStrategy(endpoints []string) *ignorePathStrategy {
 		} else if rule, ok = strings.CutSuffix(endpoint, "*"); ok {
 			result.prefixes = append(result.prefixes, rule)
 		} else {
-			result.ignoreEndpoints.AddOne(endpoint)
+			result.endpoints.AddOne(endpoint)
 		}
 	}
 
 	return result
 }
 
-// Check will return true if URL path should be ignored.
-func (s ignorePathStrategy) Check(path string) bool {
-	if s.ignoreEndpoints.Has(path) {
-		return true
+func (s StarRulePathResolver) IsPathMatching(path string) bool {
+	if s.endpoints.Has(path) {
+		return s.pathMatchingCallback(true)
 	}
 
 	for _, prefix := range s.prefixes {
 		if strings.HasPrefix(path, prefix) {
-			return true
+			return s.pathMatchingCallback(true)
 		}
 	}
 
 	for _, suffix := range s.suffixes {
 		if strings.HasSuffix(path, suffix) {
-			return true
+			return s.pathMatchingCallback(true)
 		}
 	}
 
-	return false
+	return s.pathMatchingCallback(false)
 }


Test patch:
diff --git a/test/utils/mockutils/mockcond/README.md b/test/utils/mockutils/mockcond/README.md
new file mode 100644
index 00000000..48787161
--- /dev/null
+++ b/test/utils/mockutils/mockcond/README.md
@@ -0,0 +1,86 @@
+# Description
+
+This package is used by `mockserver` it provides common HTTP request checks.
+
+## Key Features
+* Validate HTTP request properties (method, headers, query parameters, body, etc.).
+* Combine simple conditions into complex, nested conditional expressions.
+* Extendable by implementing custom conditions.
+
+# Usage
+
+## Simple Conditions
+
+Simple conditions check if an HTTP request meets specific criteria.
+You can create your own custom conditions by implementing the `mockcond.Condition` interface.
+
+### Examples
+
+Match a specific URL path suffix:
+```go
+If:   mockcond.PathSuffix("/v2/tasks"),
+```
+
+Ensure the request method is POST:
+```go
+If:    mockcond.MethodPOST(),
+```
+
+Check for a query parameter with a specific value:
+```go
+If:    mockcond.QueryParam("startAt", "17"),
+```
+
+Ensure a query parameter is missing:
+```go
+If:    mockcond.QueryParamsMissing("updated_at[gte]"),
+```
+
+Check for a specific header:
+```go
+If:    mockcond.Header(testApiVersionHeader),
+```
+
+Match the exact request body content:
+```go
+If:    mockcond.Body(bodyRequest),
+```
+
+## Complex Conditions
+
+You can combine conditions using logical operators like `And` & `Or`. 
+These operators allow for nested conditions, enabling you to create complex validation expressions 
+that resemble logical conditionals in code.
+
+
+### Examples
+
+Check for a header and ensure the method is DELETE:
+```go
+If: mockcond.And{
+	mockcond.MethodDELETE(),
+	mockcond.Header(testApiVersionHeader),
+},
+```
+Match a specific URL path suffix for either GET or POST methods: 
+```go
+If: mockcond.And{
+    mockcond.PathSuffix("/api/resource"),
+    mockcond.Or{
+        mockcond.MethodGET(),
+        mockcond.MethodPOST(),
+    },
+},
+```
+
+## Custom Conditions
+You can create custom conditions by implementing the `mockcond.Condition` interface to suit your specific requirements.
+Here is a basic example of how to create a custom condition:
+```go
+type CustomCondition struct {}
+
+func (c CustomCondition) EvaluateCondition(w http.ResponseWriter, r *http.Request) bool {
+	// Implementation goes here. 
+	return true
+}
+```
\ No newline at end of file
diff --git a/test/utils/mockutils/mockserver/README.md b/test/utils/mockutils/mockserver/README.md
new file mode 100644
index 00000000..bd441070
--- /dev/null
+++ b/test/utils/mockutils/mockserver/README.md
@@ -0,0 +1,86 @@
+# Description
+
+This folder contains utility methods to mock API calls made by connectors to provider APIs. 
+It offers several types of mock servers to simulate different scenarios by evaluating incoming 
+requests and returning appropriate responses.
+
+## Mock Servers
+The package provides multiple mock server types:
+* **Dummy**: always returns a predefined status.
+* **Fixed**: returns a fixed response regardless of the request.
+* **Conditional**: (equivalent to `if`) returns responses based on request conditions.
+* **Switch**: matches requests against multiple conditions and selects the first matching response.
+
+# Without this package
+
+A typical mock server setup without using this package would look like:
+```go
+Server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	_, _ = w.Write(data)
+})),
+```
+For more complex connectors, this approach becomes unwieldy. 
+A connector may make multiple API calls, each requiring specific request conditions to be met.
+This package simplifies such scenarios.
+
+# Usage Examples
+
+## Dummy Mock Server
+
+This mock server always returns StatusTeapot. It’s useful for tests that don’t yet need to handle API calls.
+```go
+Server: mockserver.Dummy()
+```
+
+## Fixed Mock Server
+
+This server will always return a 200 status code with a predefined response (in this case, customers).
+It’s ideal for simple tests that only need a static response.
+```go
+Server: mockserver.Fixed{
+	Setup:  mockserver.ContentJSON(),
+	Always: mockserver.Response(http.StatusOK, customers),
+}.Server(),
+```
+
+## Conditional Mock Server
+
+This mock server returns a 200 status code along with response data (e.g., indicating a job was created) 
+only if the conditions are met. The conditions in this example require that both the URL path and the request 
+body match expected values. If the conditions are not met, an optional Else clause can specify a default response 
+(otherwise the server defaults to a 500 status with a failure message).
+This is highly useful to ensure that requests are constructed properly by the connector with respect to provider APIs.
+```go
+Server: mockserver.Conditional{
+	Setup: mockserver.ContentJSON(),
+	If: mockcond.And{
+		mockcond.PathSuffix("/services/data/v59.0/jobs/ingest"),
+		mockcond.Body(bodyRequest),
+	},
+	Then: mockserver.Response(http.StatusOK, responseCreateJob),
+    Else: mockserver.Response(http.StatusOK, []byte{})
+}.Server(),
+```
+
+## Switch Mock Server
+
+This mock server behaves like a Go switch statement, evaluating multiple cases and returning the response 
+for the first matching condition. In this example, it selects the response based on the request URL path, 
+with a default response if no cases match.
+This is particularly useful when a connector makes multiple API calls, each requiring a different response.
+
+```go
+Server: mockserver.Switch{
+	Setup: mockserver.ContentJSON(),
+	Cases: []mockserver.Case{{
+		If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='account')"),
+		Then: mockserver.Response(http.StatusOK, responseContactsSchema),
+	}, {
+		If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='account')/Attributes"),
+		Then: mockserver.ResponseString(http.StatusOK, `{"value":[]}`),
+	}},
+	Default: mockserver.Response(http.StatusOK, []byte{}),
+}.Server(),
+```
diff --git a/tools/fileconv/api3/strategy_test.go b/tools/fileconv/api3/strategy_test.go
new file mode 100644
index 00000000..91bd1f6e
--- /dev/null
+++ b/tools/fileconv/api3/strategy_test.go
@@ -0,0 +1,69 @@
+package api3
+
+import (
+	"testing"
+
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestStarRulePathResolver(t *testing.T) { // nolint:funlen
+	t.Parallel()
+
+	type inType struct {
+		endpoints []string
+		path      string
+	}
+
+	tests := []struct {
+		name     string
+		input    inType
+		expected bool
+	}{
+		{
+			name: "No endpoints prohibit all paths",
+			input: inType{
+				endpoints: []string{},
+				path:      "/customer",
+			},
+			expected: false,
+		},
+		{
+			name: "Exact match",
+			input: inType{
+				endpoints: []string{"/customer"},
+				path:      "/customer",
+			},
+			expected: true,
+		},
+		{
+			name: "Prefix match",
+			input: inType{
+				endpoints: []string{"*/search"},
+				path:      "/orders/products/search",
+			},
+			expected: true,
+		},
+		{
+			name: "Suffix match",
+			input: inType{
+				endpoints: []string{"/v3/*"},
+				path:      "/v3/coupons",
+			},
+			expected: true,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			resolver := newStarRulePathResolver(tt.input.endpoints, func(matched bool) bool {
+				return matched
+			})
+			output := resolver.IsPathMatching(tt.input.path)
+			testutils.CheckOutputWithError(t, tt.name, tt.expected, nil, output, nil)
+		})
+	}
+}