Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5e9f064
TT-16890, initial implementation of plan D proposal
andrei-tyk Apr 6, 2026
926c47c
TT-16890, improved variant
andrei-tyk Apr 6, 2026
43de6c7
Merge branch 'master' into TT-16890-critical-regression-validate-requ…
lghiur Apr 6, 2026
0c5996a
TT-16890, fix: disambiguate validate request for same-base-path endpo…
andrei-tyk Apr 7, 2026
c8450d6
merge origin/master and resolve conflicts
andrei-tyk Apr 7, 2026
17e3a6c
TT-16890, fixed edge case with priority sorting of patterns
andrei-tyk Apr 8, 2026
c78842b
TT-16890, refactored for readability
andrei-tyk Apr 8, 2026
27df0a9
TT-16890, refactored to take into account pattern length
andrei-tyk Apr 8, 2026
5613176
TT-16890, refactored to take into varius number types
andrei-tyk Apr 8, 2026
3269ef8
TT-16890, refactored to improve readability and to fix mock response mw
andrei-tyk Apr 8, 2026
adf91a5
TT-16890, added more tests to validate correct schema interpretation
andrei-tyk Apr 8, 2026
7cc9e86
TT-16890, fixed fallback for mock response
andrei-tyk Apr 8, 2026
efc031a
Merge branch 'master' into TT-16890-v2
ilijabojanovic Apr 8, 2026
624431a
Merge branch 'master' into TT-16890-v2
andrei-tyk Apr 9, 2026
963ef0e
TT-16890, sonar
andrei-tyk Apr 9, 2026
7970fc0
TT-16890, fix for PR https://github.com/TykTechnologies/tyk/pull/7898
andrei-tyk Apr 9, 2026
8415f33
Merge branch 'master' into TT-16890-v2
ilijabojanovic Apr 13, 2026
8939caf
Merge branch 'master' into TT-16890-v2
lghiur Apr 14, 2026
e734241
Merge branch 'master' into TT-16890-v2
lghiur Apr 14, 2026
c82c1e7
Merge branch 'master' into TT-16890-v2
lghiur Apr 15, 2026
a9df9e6
Merge branch 'master' into TT-16890-v2
lghiur Apr 15, 2026
30135d5
Merge branch 'master' into TT-16890-v2
lghiur Apr 15, 2026
e626227
Merge branch 'master' into TT-16890-v2
buger Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions gateway/api_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,29 +81,29 @@
CircuitBreaker
URLRewrite
VirtualPath
RequestSizeLimit
MethodTransformed
RequestTracked
RequestNotTracked
ValidateJSONRequest
OASValidateRequest
Internal
GoPlugin
PersistGraphQL
RateLimit
OASMockResponse
)

// RequestStatus is a custom type to avoid collisions
type RequestStatus string

// Statuses of the request, all are false-y except StatusOk and StatusOkAndIgnore
const (
VersionNotFound RequestStatus = "Version information not found"
VersionDoesNotExist RequestStatus = "This API version does not seem to exist"
VersionWhiteListStatusNotFound RequestStatus = "WhiteListStatus for path not found"
VersionExpired RequestStatus = "Api Version has expired, please check documentation or contact administrator"
VersionDefaultForNotVersionedNotFound RequestStatus = "No default API version for this non-versioned API found"

Check warning on line 106 in gateway/api_definition.go

View check run for this annotation

probelabs / Visor: quality

style Issue

The functions `mergeMockGroupIntoPrimary` and `mergeGroupIntoPrimary` (line 120) are structurally identical, differing only in the types and field names they handle. This represents code duplication that could be reduced.
Raw output
Refactor the common logic into a single function. Given the constraints of Go's type system (depending on the version used), this might involve using interfaces and type switches, or if available, generics. If a refactor is deemed too complex, add a comment acknowledging the duplication and the reason for it.
VersionAmbiguousDefault RequestStatus = "Ambiguous default API version for this non-versioned API"
APIExpired RequestStatus = "API has expired, please check documentation or contact administrator"
EndPointNotAllowed RequestStatus = "Requested endpoint is forbidden"
Expand Down Expand Up @@ -158,7 +158,7 @@
type OAuthManagerInterface interface {
Storage() ExtendedOsinStorageInterface
}

Check warning on line 161 in gateway/api_definition.go

View check run for this annotation

probelabs / Visor: performance

performance Issue

The `removeIndices` function creates a new slice and appends elements to it in a loop. While it correctly pre-allocates capacity, a more efficient approach for removing elements from a slice is to use a two-pointer technique to modify the slice in-place. This avoids the memory allocation and copying overhead of creating a new slice, especially if the number of specs is large.
Raw output
Modify the `removeIndices` function to perform an in-place removal. Iterate through the slice, maintaining a separate index for writing. If an element is not in the `toRemove` map, copy it to the current write position and increment the write index. Finally, return a sub-slice of the original slice up to the final write index. This avoids allocating a new slice.
// GetSessionLifetimeRespectsKeyExpiration returns a boolean to tell whether session lifetime should respect to key expiration or not.
// The global config takes the precedence. If the global one is `true`, value of the one in api level doesn't matter.
func (a *APISpec) GetSessionLifetimeRespectsKeyExpiration() bool {
Expand Down Expand Up @@ -1389,6 +1389,8 @@
urlSpec = append(urlSpec, newSpec)
}

urlSpec = groupCollapsedValidateRequestSpecs(urlSpec, apiSpec.OAS.Paths)

urlSpec = a.addStaticPathShields(apiSpec, conf, urlSpec, OASValidateRequest, func(path, method string) URLSpec {
return URLSpec{
OASValidateRequestMeta: &oas.ValidateRequest{Enabled: false},
Expand All @@ -1402,6 +1404,240 @@
return urlSpec
}

// groupCollapsedValidateRequestSpecs detects URLSpec entries that compile to the same
// regex+method pair and groups them as candidates on a single representative URLSpec.
// Candidates are sorted so that more restrictive path parameter schemas are tried first.
func groupCollapsedValidateRequestSpecs(specs []URLSpec, oasPaths *openapi3.Paths) []URLSpec {
return groupCollapsedSpecs(specs, oasPaths, OASValidateRequest, func(indices []int, specs []URLSpec, toRemove map[int]bool) {
mergeGroupIntoPrimary(indices, specs, toRemove)
})
}

// groupCollapsedMockResponseSpecs is the mock response equivalent of
// groupCollapsedValidateRequestSpecs.
func groupCollapsedMockResponseSpecs(specs []URLSpec, oasPaths *openapi3.Paths) []URLSpec {
return groupCollapsedSpecs(specs, oasPaths, OASMockResponse, func(indices []int, specs []URLSpec, toRemove map[int]bool) {
mergeMockGroupIntoPrimary(indices, specs, toRemove)
})
}

// groupCollapsedSpecs is the shared grouping logic for both validate request and mock
// response. It finds URLSpec entries with the same compiled regex+method, sorts them
// by restrictiveness, and calls the provided merge function to build candidates.
func groupCollapsedSpecs(
specs []URLSpec,
oasPaths *openapi3.Paths,
status URLStatus,
merge func(indices []int, specs []URLSpec, toRemove map[int]bool),
) []URLSpec {
type key struct {
regex string
method string
}

groups := make(map[key][]int)
var order []key
for i, s := range specs {
if s.Status != status || s.spec == nil {
continue
}
k := key{regex: s.spec.String(), method: s.OASMethod}
if _, exists := groups[k]; !exists {
order = append(order, k)
}
groups[k] = append(groups[k], i)
}

toRemove := make(map[int]bool)
for _, k := range order {
indices := groups[k]
if len(indices) < 2 {
continue
}
sortByRestrictiveness(indices, specs, oasPaths)
merge(indices, specs, toRemove)
}

return removeIndices(specs, toRemove)
}

// mergeMockGroupIntoPrimary builds MockResponseCandidates from the sorted indices
// and assigns them to the primary (first) spec.
func mergeMockGroupIntoPrimary(indices []int, specs []URLSpec, toRemove map[int]bool) {
primary := indices[0]
candidates := make([]MockResponseCandidate, len(indices))
for ci, idx := range indices {
candidates[ci] = MockResponseCandidate{
OASMockResponseMeta: specs[idx].OASMockResponseMeta,
OASMethod: specs[idx].OASMethod,
OASPath: specs[idx].OASPath,
}
if idx != primary {
toRemove[idx] = true
}
}

specs[primary].OASMockResponseMeta = candidates[0].OASMockResponseMeta
specs[primary].OASMethod = candidates[0].OASMethod
specs[primary].OASPath = candidates[0].OASPath
specs[primary].OASMockResponseCandidates = candidates
}

// sortByRestrictiveness sorts spec indices so that more restrictive path parameter
// schemas come first. Ties in restrictiveness score are broken by total pattern length
// (longer patterns are more specific, e.g., ^\d+$ before .*), then alphabetically.
func sortByRestrictiveness(indices []int, specs []URLSpec, oasPaths *openapi3.Paths) {
sort.Slice(indices, func(a, b int) bool {
scoreA := pathParamRestrictiveness(specs[indices[a]].OASPath, specs[indices[a]].OASMethod, oasPaths)
scoreB := pathParamRestrictiveness(specs[indices[b]].OASPath, specs[indices[b]].OASMethod, oasPaths)
if scoreA != scoreB {
return scoreA > scoreB
}
lenA := pathParamPatternLength(specs[indices[a]].OASPath, specs[indices[a]].OASMethod, oasPaths)
lenB := pathParamPatternLength(specs[indices[b]].OASPath, specs[indices[b]].OASMethod, oasPaths)
if lenA != lenB {
return lenA > lenB
}
return specs[indices[a]].OASPath < specs[indices[b]].OASPath
})
}

// mergeGroupIntoPrimary takes a sorted list of spec indices that share the same
// regex+method, builds ValidateRequestCandidates from them, and assigns them to
// the primary (first) spec. Non-primary indices are marked for removal.
func mergeGroupIntoPrimary(indices []int, specs []URLSpec, toRemove map[int]bool) {
primary := indices[0]
candidates := make([]ValidateRequestCandidate, len(indices))
for ci, idx := range indices {
candidates[ci] = ValidateRequestCandidate{
OASValidateRequestMeta: specs[idx].OASValidateRequestMeta,
OASMethod: specs[idx].OASMethod,
OASPath: specs[idx].OASPath,
}
if idx != primary {
toRemove[idx] = true
}
}

specs[primary].OASValidateRequestMeta = candidates[0].OASValidateRequestMeta
specs[primary].OASMethod = candidates[0].OASMethod
specs[primary].OASPath = candidates[0].OASPath
specs[primary].OASValidateRequestCandidates = candidates
}

// removeIndices returns a new slice with entries at the given indices removed.
func removeIndices(specs []URLSpec, toRemove map[int]bool) []URLSpec {
if len(toRemove) == 0 {
return specs
}
result := make([]URLSpec, 0, len(specs)-len(toRemove))
for i, s := range specs {
if !toRemove[i] {
result = append(result, s)
}
}
return result
}

// pathParamRestrictiveness returns a score indicating how restrictive the path parameter
// schemas are for a given OAS path+method. Higher scores mean more restrictive. A plain
// type:string with no constraints scores 0 (catch-all), while type:number, type:integer,
// type:boolean, or any parameter with a pattern/enum/format scores higher.
func pathParamRestrictiveness(oasPath, method string, oasPaths *openapi3.Paths) int {
if oasPaths == nil {
return 0
}
pathItem := oasPaths.Value(oasPath)
if pathItem == nil {
return 0
}
op := pathItem.GetOperation(method)
if op == nil {
return 0
}

score := 0
for _, paramRef := range op.Parameters {
if paramRef == nil || paramRef.Value == nil || paramRef.Value.In != "path" {
continue
}
param := paramRef.Value
if param.Schema == nil || param.Schema.Value == nil {
continue
}
score += schemaRestrictiveness(param.Schema.Value)
}
return score
}

// schemaRestrictiveness returns a score for how restrictive a single path parameter
// schema is. The hierarchy from most to least restrictive:
//
// integer (7) > number (6) > boolean (5) > array (4) > object (3)
// > string with constraints (2) > unconstrained string (0)
//
// This ensures that integer parameters are tried before number (since every integer
// is a valid number but not vice versa), and all typed parameters are tried before
// string which accepts everything.
func schemaRestrictiveness(s *openapi3.Schema) int {
if s.Type != nil {
switch {
case s.Type.Is("integer"):
return 7
case s.Type.Is("number"):
return 6
case s.Type.Is("boolean"):
return 5
case s.Type.Is("array"):
return 4
case s.Type.Is("object"):
return 3
}
}

// type:string or untyped — check for constraints.
hasPattern := s.Pattern != ""
hasEnum := len(s.Enum) > 0
hasFormat := s.Format != ""
hasMinLen := s.MinLength != 0
hasMaxLen := s.MaxLength != nil

if hasPattern || hasEnum || hasFormat || hasMinLen || hasMaxLen {
return 2
}

// Unconstrained string — matches everything.
return 0
}

// pathParamPatternLength returns the total length of all path parameter pattern strings
// for a given OAS path+method. Used as a tie-breaker when restrictiveness scores are
// equal — longer patterns tend to be more specific (e.g., ^\d+$ vs .*).
func pathParamPatternLength(oasPath, method string, oasPaths *openapi3.Paths) int {
if oasPaths == nil {
return 0
}
pathItem := oasPaths.Value(oasPath)
if pathItem == nil {
return 0
}
op := pathItem.GetOperation(method)
if op == nil {
return 0
}

total := 0
for _, paramRef := range op.Parameters {
if paramRef == nil || paramRef.Value == nil || paramRef.Value.In != "path" {
continue
}
if paramRef.Value.Schema != nil && paramRef.Value.Schema.Value != nil {
total += len(paramRef.Value.Schema.Value.Pattern)
}
}
return total
}

// compileOASMockResponsePathSpec extracts MockResponse operations from OAS middleware
// and converts them to URLSpec entries that use the standard regex-based path matching algorithm.
// This ensures OAS mockResponse middleware respects gateway configurations like
Expand Down Expand Up @@ -1439,6 +1675,8 @@
urlSpec = append(urlSpec, newSpec)
}

urlSpec = groupCollapsedMockResponseSpecs(urlSpec, apiSpec.OAS.Paths)

urlSpec = a.addStaticPathShields(apiSpec, conf, urlSpec, OASMockResponse, func(path, method string) URLSpec {
return URLSpec{
OASMockResponseMeta: &oas.MockResponse{Enabled: false},
Expand Down
24 changes: 24 additions & 0 deletions gateway/model_apispec.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sync"
"time"

"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/routers"

"github.com/TykTechnologies/tyk-pump/analytics"
Expand Down Expand Up @@ -303,6 +304,29 @@ func (a *APISpec) findRouteForOASPath(oasPath, method, actualPath, fullRequestPa
return route, pathParams, nil
}

// matchCandidatePath looks up the path item and operation from the OAS spec for a
// candidate path+method, extracts path parameters from the actual request path, and
// validates them against the operation's path parameter schemas. Returns the operation,
// path params, and true if the candidate matches; false otherwise.
// This is the shared disambiguation logic used by both validate request and mock response.
func (a *APISpec) matchCandidatePath(oasPath, oasMethod, strippedPath string) (*openapi3.PathItem, *openapi3.Operation, map[string]string, bool) {
pathItem := a.OAS.Paths.Value(oasPath)
if pathItem == nil {
return nil, nil, nil, false
}
operation := pathItem.GetOperation(oasMethod)
if operation == nil {
return nil, nil, nil, false
}

pathParams := extractPathParams(oasPath, strippedPath)
if !pathParamsMatchOperation(pathParams, operation) {
return nil, nil, nil, false
}

return pathItem, operation, pathParams, true
}

// extractPathParams extracts path parameter values from actualPath based on the
// OAS path pattern. For example, if oasPath is "/users/{id}" and actualPath is
// "/users/123", it returns map[string]string{"id": "123"}.
Expand Down
27 changes: 27 additions & 0 deletions gateway/model_urlspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ type URLSpec struct {
OASValidateRequestMeta *oas.ValidateRequest
OASMockResponseMeta *oas.MockResponse

// OASValidateRequestCandidates holds multiple OAS endpoints that compile to the
// same regex pattern. When non-empty, the validate request middleware must
// disambiguate by checking path parameter schemas against each candidate.
OASValidateRequestCandidates []ValidateRequestCandidate

// OASMockResponseCandidates holds multiple OAS endpoints that compile to the
// same regex pattern. When non-empty, the mock response middleware must
// disambiguate by checking path parameter schemas against each candidate.
OASMockResponseCandidates []MockResponseCandidate

IgnoreCase bool
// OASMethod stores the HTTP method for OAS-specific middleware
// This is needed because OAS operations are method-specific
Expand All @@ -50,6 +60,23 @@ type URLSpec struct {
OASPath string
}

// ValidateRequestCandidate represents one OAS endpoint that maps to the same
// compiled regex pattern. Used for disambiguation when multiple parameterized
// paths collapse to the same regex (e.g., /employees/{prct} and /employees/{zd}).
type ValidateRequestCandidate struct {
OASValidateRequestMeta *oas.ValidateRequest
OASMethod string
OASPath string
}

// MockResponseCandidate represents one OAS endpoint that maps to the same
// compiled regex pattern for mock response disambiguation.
type MockResponseCandidate struct {
OASMockResponseMeta *oas.MockResponse
OASMethod string
OASPath string
}

// modeSpecificSpec returns the respective field of URLSpec if it matches the given mode.
// Deprecated: Usage should not increase.
func (u *URLSpec) modeSpecificSpec(mode URLStatus) (interface{}, bool) {
Expand Down
Loading
Loading