diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index f3926b0181..5b011d7e57 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -4207,6 +4207,14 @@ components: default: deployed example: deployed + UpstreamReference: + type: string + description: Name of a predefined upstreamDefinition. + minLength: 1 + maxLength: 100 + pattern: '^[a-zA-Z0-9\-_]+$' + example: my-upstream-1 + UpstreamDefinition: type: object required: @@ -4215,12 +4223,7 @@ components: description: Reusable upstream configuration with optional timeout and load balancing settings properties: name: - type: string - description: Unique identifier for this upstream definition - minLength: 1 - maxLength: 100 - pattern: '^[a-zA-Z0-9\-_]+$' - example: my-upstream-1 + $ref: "#/components/schemas/UpstreamReference" basePath: type: string description: Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. @@ -4271,8 +4274,7 @@ components: description: Direct backend URL to route traffic to example: http://prod-backend:5000/api/v2 ref: - type: string - description: Reference to a predefined upstreamDefinition + $ref: "#/components/schemas/UpstreamReference" hostRewrite: type: string enum: @@ -4313,6 +4315,29 @@ components: description: List of policies applied only to this operation (overrides or adds to API-level policies) items: $ref: "#/components/schemas/Policy" + upstream: + $ref: "#/components/schemas/RestAPIOperationUpstream" + + RestAPIOperationUpstream: + type: object + additionalProperties: false + description: Per-operation upstream override. Each sub-field must reference a named entry in spec.upstreamDefinitions. Missing sub-fields fall back to API-level upstream. At least one of main or sandbox must be set. + minProperties: 1 + properties: + main: + $ref: "#/components/schemas/RestAPIOperationUpstreamTarget" + sandbox: + $ref: "#/components/schemas/RestAPIOperationUpstreamTarget" + + RestAPIOperationUpstreamTarget: + type: object + additionalProperties: false + required: + - ref + description: A ref-only upstream pointer for operation-level overrides. URLs are not permitted at the operation level; all backend URLs must be declared in spec.upstreamDefinitions and referenced by name. + properties: + ref: + $ref: "#/components/schemas/UpstreamReference" Policy: type: object diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 4c26886a3b..5663ff14fe 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -431,6 +431,9 @@ func main() { llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer) policyManager.SetTransformers(transformerRegistry) + // In this controller wiring, only policy xDS receives the transformer + // registry. Main Envoy xDS still translates RestAPI configs directly, so + // both paths must keep cluster-name derivation in sync. // Load runtime configs from existing API configurations on startup. // We write directly to runtimeStore to avoid triggering N separate snapshot updates; diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 845ab7d922..3cd946ca17 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -822,8 +822,8 @@ type LLMProviderConfigData_Upstream struct { // HostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. HostRewrite *LLMProviderConfigDataUpstreamHostRewrite `json:"hostRewrite,omitempty" yaml:"hostRewrite,omitempty"` - // Ref Reference to a predefined upstreamDefinition - Ref *string `json:"ref,omitempty" yaml:"ref,omitempty"` + // Ref Name of a predefined upstreamDefinition. + Ref *UpstreamReference `json:"ref,omitempty" yaml:"ref,omitempty"` // Url Direct backend URL to route traffic to Url *string `json:"url,omitempty" yaml:"url,omitempty"` @@ -1106,8 +1106,8 @@ type MCPProxyConfigData_Upstream struct { // HostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. HostRewrite *MCPProxyConfigDataUpstreamHostRewrite `json:"hostRewrite,omitempty" yaml:"hostRewrite,omitempty"` - // Ref Reference to a predefined upstreamDefinition - Ref *string `json:"ref,omitempty" yaml:"ref,omitempty"` + // Ref Name of a predefined upstreamDefinition. + Ref *UpstreamReference `json:"ref,omitempty" yaml:"ref,omitempty"` // Url Direct backend URL to route traffic to Url *string `json:"url,omitempty" yaml:"url,omitempty"` @@ -1212,6 +1212,9 @@ type Operation struct { // Policies List of policies applied only to this operation (overrides or adds to API-level policies) Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + + // Upstream Per-operation upstream override. Each sub-field must reference a named entry in spec.upstreamDefinitions. Missing sub-fields fall back to API-level upstream. At least one of main or sandbox must be set. + Upstream *RestAPIOperationUpstream `json:"upstream,omitempty" yaml:"upstream,omitempty"` } // OperationMethod HTTP method @@ -1294,6 +1297,21 @@ type RestAPIApiVersion string // RestAPIKind API type type RestAPIKind string +// RestAPIOperationUpstream Per-operation upstream override. Each sub-field must reference a named entry in spec.upstreamDefinitions. Missing sub-fields fall back to API-level upstream. At least one of main or sandbox must be set. +type RestAPIOperationUpstream struct { + // Main A ref-only upstream pointer for operation-level overrides. URLs are not permitted at the operation level; all backend URLs must be declared in spec.upstreamDefinitions and referenced by name. + Main *RestAPIOperationUpstreamTarget `json:"main,omitempty" yaml:"main,omitempty"` + + // Sandbox A ref-only upstream pointer for operation-level overrides. URLs are not permitted at the operation level; all backend URLs must be declared in spec.upstreamDefinitions and referenced by name. + Sandbox *RestAPIOperationUpstreamTarget `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` +} + +// RestAPIOperationUpstreamTarget A ref-only upstream pointer for operation-level overrides. URLs are not permitted at the operation level; all backend URLs must be declared in spec.upstreamDefinitions and referenced by name. +type RestAPIOperationUpstreamTarget struct { + // Ref Name of a predefined upstreamDefinition. + Ref UpstreamReference `json:"ref" yaml:"ref"` +} + // RestAPIRequest defines model for RestAPIRequest. type RestAPIRequest struct { // ApiVersion API specification version @@ -1565,8 +1583,8 @@ type Upstream struct { // HostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. HostRewrite *UpstreamHostRewrite `json:"hostRewrite,omitempty" yaml:"hostRewrite,omitempty"` - // Ref Reference to a predefined upstreamDefinition - Ref *string `json:"ref,omitempty" yaml:"ref,omitempty"` + // Ref Name of a predefined upstreamDefinition. + Ref *UpstreamReference `json:"ref,omitempty" yaml:"ref,omitempty"` // Url Direct backend URL to route traffic to Url *string `json:"url,omitempty" yaml:"url,omitempty"` @@ -1599,8 +1617,8 @@ type UpstreamDefinition struct { // BasePath Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. BasePath *string `json:"basePath,omitempty" yaml:"basePath,omitempty"` - // Name Unique identifier for this upstream definition - Name string `json:"name" yaml:"name"` + // Name Name of a predefined upstreamDefinition. + Name UpstreamReference `json:"name" yaml:"name"` // Timeout Timeout configuration for upstream requests Timeout *UpstreamTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` @@ -1615,6 +1633,9 @@ type UpstreamDefinition struct { } `json:"upstreams" yaml:"upstreams"` } +// UpstreamReference Name of a predefined upstreamDefinition. +type UpstreamReference = string + // UpstreamTimeout Timeout configuration for upstream requests type UpstreamTimeout struct { // Connect Connection timeout duration (e.g., "5s", "500ms") @@ -5241,281 +5262,285 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "AP//COn1vsNeAgA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index bb0698f66a..0d9d09e71a 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -26,6 +26,7 @@ import ( "time" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/upstreamref" ) // APIValidator validates API configurations using rule-based validation @@ -36,6 +37,10 @@ type APIValidator struct { versionRegex *regexp.Regexp // urlFriendlyNameRegex matches URL-safe characters for API names urlFriendlyNameRegex *regexp.Regexp + // upstreamRefRegex enforces the schema pattern for per-op upstream refs + upstreamRefRegex *regexp.Regexp + // connectTimeoutRegex enforces the ms|s|m|h unit contract for upstream connect timeouts + connectTimeoutRegex *regexp.Regexp // policyValidator validates policy references and parameters policyValidator *PolicyValidator } @@ -46,6 +51,8 @@ func NewAPIValidator() *APIValidator { pathParamRegex: regexp.MustCompile(`\{[a-zA-Z0-9_]+\}`), versionRegex: regexp.MustCompile(`^v?\d+(\.\d+)?(\.\d+)?$`), urlFriendlyNameRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_\. ]+$`), + upstreamRefRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`), + connectTimeoutRegex: regexp.MustCompile(`^\d+(\.\d+)?(ms|s|m|h)$`), } } @@ -235,6 +242,20 @@ func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDe refName := strings.TrimSpace(*ref) + if len(refName) > 100 { + return []ValidationError{{ + Field: "spec.upstream." + label + ".ref", + Message: "Upstream ref must not exceed 100 characters", + }} + } + + if !v.upstreamRefRegex.MatchString(refName) { + return []ValidationError{{ + Field: "spec.upstream." + label + ".ref", + Message: "Upstream ref must match pattern ^[a-zA-Z0-9\\-_]+$", + }} + } + // Check if upstream definitions are provided if upstreamDefinitions == nil || len(*upstreamDefinitions) == 0 { errors = append(errors, ValidationError{ @@ -244,16 +265,10 @@ func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDe return errors } - // Check if the referenced definition exists - found := false - for _, def := range *upstreamDefinitions { - if def.Name == refName { - found = true - break - } - } - - if !found { + // Check if the referenced definition exists. Use the shared upstreamref helper + // for the membership lookup so API-level ref validation stays aligned with the + // per-op validator and the translators (one source of truth for ref lookup). + if _, err := upstreamref.FindByName(refName, upstreamDefinitions); err != nil { errors = append(errors, ValidationError{ Field: "spec.upstream." + label + ".ref", Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", refName), @@ -294,6 +309,23 @@ func (v *APIValidator) validateUpstreamDefinitions(definitions *[]api.UpstreamDe } namesSeen[def.Name] = true + // Enforce the same name contract the schema declares and that operation-level + // refs are validated against (^[a-zA-Z0-9\-_]+$, max 100 chars), so any valid + // definition name stays referenceable from a per-op upstream override. + if len(def.Name) > 100 { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("spec.upstreamDefinitions[%d].name", i), + Message: "Upstream definition name must not exceed 100 characters", + }) + continue + } else if !v.upstreamRefRegex.MatchString(def.Name) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("spec.upstreamDefinitions[%d].name", i), + Message: "Upstream definition name must match pattern ^[a-zA-Z0-9\\-_]+$", + }) + continue + } + // Validate upstreams array if len(def.Upstreams) == 0 { errors = append(errors, ValidationError{ @@ -372,15 +404,28 @@ func (v *APIValidator) validateUpstreamDefinitions(definitions *[]api.UpstreamDe // Timeout validation is limited to connect timeout; request and idle // timeouts are no longer supported at the upstream definition level. + // Parsed inline rather than via upstreamref.ParseConnectTimeout so the two + // distinct, tested messages below (invalid-format vs non-positive) are kept; + // the shared helper collapses both into a single message. if def.Timeout != nil && def.Timeout.Connect != nil { timeoutStr := strings.TrimSpace(*def.Timeout.Connect) if timeoutStr != "" { - _, err := time.ParseDuration(timeoutStr) + d, err := time.ParseDuration(timeoutStr) if err != nil { errors = append(errors, ValidationError{ Field: fmt.Sprintf("spec.upstreamDefinitions[%d].timeout.connect", i), Message: fmt.Sprintf("Invalid timeout format: %v (expected format: '30s', '1m', '500ms')", err), }) + } else if !v.connectTimeoutRegex.MatchString(timeoutStr) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("spec.upstreamDefinitions[%d].timeout.connect", i), + Message: fmt.Sprintf("Invalid timeout format: %q (use a single-unit, unsigned duration like '30s', '1m', or '500ms'; signed values like '+5s' and multi-unit values like '1m30s' are not supported)", timeoutStr), + }) + } else if d <= 0 { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("spec.upstreamDefinitions[%d].timeout.connect", i), + Message: "Connect timeout must be a positive duration", + }) } } } @@ -437,7 +482,7 @@ func (v *APIValidator) validateRestData(spec *api.APIConfigData) []ValidationErr } // Validate operations - errors = append(errors, v.validateOperations(spec.Operations)...) + errors = append(errors, v.validateOperations(spec.Operations, spec.UpstreamDefinitions)...) return errors } @@ -568,7 +613,7 @@ func (v *APIValidator) validatePathParametersForAsyncAPIs(path string) bool { } // validateOperations validates the operations configuration -func (v *APIValidator) validateOperations(operations []api.Operation) []ValidationError { +func (v *APIValidator) validateOperations(operations []api.Operation, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { var errors []ValidationError if len(operations) == 0 { @@ -621,11 +666,83 @@ func (v *APIValidator) validateOperations(operations []api.Operation) []Validati Message: "Operation path has unbalanced braces in parameters", }) } + + // Validate per-operation upstream override (main / sandbox) + if op.Upstream != nil { + errors = append(errors, v.validateOperationUpstream(i, op.Upstream, upstreamDefinitions)...) + } } return errors } +// validateOperationUpstream validates per-operation upstream main and sandbox +// sub-fields. Operation-level upstreams are ref-only; direct URLs are not +// permitted. Each present sub-field must reference a named entry in +// spec.upstreamDefinitions. Error field paths are built as +// spec.operations[N].upstream..ref. +func (v *APIValidator) validateOperationUpstream(opIdx int, up *api.RestAPIOperationUpstream, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { + var errors []ValidationError + if up == nil { + return errors + } + if up.Main == nil && up.Sandbox == nil { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("spec.operations[%d].upstream", opIdx), + Message: "At least one of 'main' or 'sandbox' must be set", + }) + return errors + } + if up.Main != nil { + errs := v.validateOperationUpstreamTarget(opIdx, "main", up.Main, upstreamDefinitions) + errors = append(errors, errs...) + } + if up.Sandbox != nil { + errs := v.validateOperationUpstreamTarget(opIdx, "sandbox", up.Sandbox, upstreamDefinitions) + errors = append(errors, errs...) + } + return errors +} + +// validateOperationUpstreamTarget validates a single ref-only operation-level +// upstream target. The ref must resolve to a named entry in upstreamDefinitions. +func (v *APIValidator) validateOperationUpstreamTarget(opIdx int, sub string, target *api.RestAPIOperationUpstreamTarget, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { + field := fmt.Sprintf("spec.operations[%d].upstream.%s.ref", opIdx, sub) + + refName := strings.TrimSpace(target.Ref) + if refName == "" { + return []ValidationError{{ + Field: field, + Message: "Upstream ref is required", + }} + } + + if len(refName) > 100 { + return []ValidationError{{ + Field: field, + Message: "Upstream ref must not exceed 100 characters", + }} + } + + if !v.upstreamRefRegex.MatchString(refName) { + return []ValidationError{{ + Field: field, + Message: "Upstream ref must match pattern ^[a-zA-Z0-9\\-_]+$", + }} + } + + // Resolve through the shared upstreamref helper so the validator stays aligned + // with the xDS translator and RDC transformer (one source of truth for ref lookup). + if _, err := upstreamref.FindByName(refName, upstreamDefinitions); err != nil { + return []ValidationError{{ + Field: field, + Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", refName), + }} + } + + return nil +} + // validatePathParameters checks if path parameters have balanced braces func (v *APIValidator) validatePathParameters(path string) bool { openCount := strings.Count(path, "{") diff --git a/gateway/gateway-controller/pkg/config/validator_test.go b/gateway/gateway-controller/pkg/config/validator_test.go index 6617970295..1016d842a8 100644 --- a/gateway/gateway-controller/pkg/config/validator_test.go +++ b/gateway/gateway-controller/pkg/config/validator_test.go @@ -902,6 +902,135 @@ func TestValidateUpstreamDefinitions_NoTimeout(t *testing.T) { assert.Empty(t, errors, "No timeout should be valid") } +func TestValidateUpstreamDefinitions_NonPositiveTimeout(t *testing.T) { + validator := NewAPIValidator() + + for _, badTimeout := range []string{"0s", "0ms"} { + connect := badTimeout + definitions := &[]api.UpstreamDefinition{ + { + Name: "my-upstream", + Timeout: &api.UpstreamTimeout{ + Connect: &connect, + }, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + { + Url: "http://backend:8080", + }, + }, + }, + } + + errors := validator.validateUpstreamDefinitions(definitions) + require.Len(t, errors, 1, "timeout %q must be rejected", badTimeout) + assert.Equal(t, "spec.upstreamDefinitions[0].timeout.connect", errors[0].Field) + assert.Contains(t, errors[0].Message, "must be a positive duration") + } +} + +func TestValidateUpstreamDefinitions_MalformedTimeout(t *testing.T) { + validator := NewAPIValidator() + + connect := "abc" + definitions := &[]api.UpstreamDefinition{ + { + Name: "my-upstream", + Timeout: &api.UpstreamTimeout{ + Connect: &connect, + }, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + { + Url: "http://backend:8080", + }, + }, + }, + } + + errors := validator.validateUpstreamDefinitions(definitions) + require.Len(t, errors, 1) + assert.Equal(t, "spec.upstreamDefinitions[0].timeout.connect", errors[0].Field) + assert.Contains(t, errors[0].Message, "Invalid timeout format") +} + +func TestValidateUpstreamDefinitions_TimeoutUnitContract(t *testing.T) { + validator := NewAPIValidator() + + // time.ParseDuration accepts units outside the ms|s|m|h contract (ns, us, compounds) + // and leading signs the published schema does not; these must be rejected as invalid + // format, not silently accepted. + for _, badTimeout := range []string{"5ns", "100us", "1h30m", "+5s", "-5s"} { + connect := badTimeout + definitions := &[]api.UpstreamDefinition{ + { + Name: "my-upstream", + Timeout: &api.UpstreamTimeout{ + Connect: &connect, + }, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://backend:8080"}, + }, + }, + } + + errors := validator.validateUpstreamDefinitions(definitions) + require.Len(t, errors, 1, "timeout %q must be rejected", badTimeout) + assert.Equal(t, "spec.upstreamDefinitions[0].timeout.connect", errors[0].Field) + assert.Contains(t, errors[0].Message, "Invalid timeout format") + } +} + +// TestValidateUpstreamDefinitions_NameRules covers the definition-name contract +// (max 100 chars, pattern ^[a-zA-Z0-9\-_]+$) so a valid name stays referenceable +// from a per-op upstream override. +func TestValidateUpstreamDefinitions_NameRules(t *testing.T) { + validator := NewAPIValidator() + + validUpstreams := []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://backend:8080"}, + } + + tests := []struct { + name string + defName string + wantMsg string // empty means the name is accepted + }{ + {"over-length is rejected", strings.Repeat("a", 101), "must not exceed 100 characters"}, + {"space is rejected", "bad name", "must match pattern"}, + {"dot is rejected", "has.dot", "must match pattern"}, + {"colon is rejected", "has:colon", "must match pattern"}, + {"slash is rejected", "has/slash", "must match pattern"}, + {"valid name is accepted", "valid-name_123", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + definitions := &[]api.UpstreamDefinition{ + {Name: tt.defName, Upstreams: validUpstreams}, + } + errors := validator.validateUpstreamDefinitions(definitions) + if tt.wantMsg == "" { + assert.Empty(t, errors) + return + } + require.Len(t, errors, 1) + assert.Equal(t, "spec.upstreamDefinitions[0].name", errors[0].Field) + assert.Contains(t, errors[0].Message, tt.wantMsg) + }) + } +} + func TestValidateUpstreamRef_ValidRef(t *testing.T) { validator := NewAPIValidator() @@ -983,3 +1112,277 @@ func TestValidateUpstream_WithRefAndDefinitions(t *testing.T) { errors := validator.validateUpstream("main", upstream, definitions) assert.Empty(t, errors) } + +// TestValidateOperationUpstream_ValidRef asserts that a well-formed ref passes validation +// when it resolves to a known upstreamDefinition. +func TestValidateOperationUpstream_ValidRef(t *testing.T) { + validator := NewAPIValidator() + definitions := &[]api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + } + up := &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-cluster"}, + } + + errors := validator.validateOperationUpstream(0, up, definitions) + assert.Empty(t, errors) +} + +// TestValidateOperationUpstream_EmptyRef asserts that an empty ref is rejected +// with a per-op-scoped error field path. +func TestValidateOperationUpstream_EmptyRef(t *testing.T) { + validator := NewAPIValidator() + up := &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: ""}, + } + + errors := validator.validateOperationUpstream(2, up, nil) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[2].upstream.main") { + found = true + break + } + } + assert.True(t, found, "validation error should be scoped to spec.operations[2].upstream.main, got %+v", errors) +} + +// TestValidateOperationUpstream_UnknownRef asserts that a ref not matching any +// upstreamDefinition is rejected. +func TestValidateOperationUpstream_UnknownRef(t *testing.T) { + validator := NewAPIValidator() + up := &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "missing-cluster"}, + } + definitions := &[]api.UpstreamDefinition{ + { + Name: "user-svc-cluster", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://user-svc:8080"}, + }, + }, + } + + errors := validator.validateOperationUpstream(0, up, definitions) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main.ref") { + found = true + break + } + } + assert.True(t, found, "expected unknown-ref error scoped to main.ref, got %+v", errors) +} + +// TestValidateOperationUpstream_EmptyWrapper asserts that a wrapper with neither +// main nor sandbox set is rejected. +func TestValidateOperationUpstream_EmptyWrapper(t *testing.T) { + validator := NewAPIValidator() + up := &api.RestAPIOperationUpstream{} + + errors := validator.validateOperationUpstream(3, up, nil) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if e.Field == "spec.operations[3].upstream" && + strings.Contains(strings.ToLower(e.Message), "at least one") { + found = true + break + } + } + assert.True(t, found, "expected 'at least one' error at wrapper level, got %+v", errors) +} + +// TestValidateOperationUpstream_SandboxUnknownRef asserts the sandbox sub-field is +// validated too (the existence check runs for sandbox), with a sandbox-scoped field path. +func TestValidateOperationUpstream_SandboxUnknownRef(t *testing.T) { + validator := NewAPIValidator() + definitions := &[]api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + } + up := &api.RestAPIOperationUpstream{ + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "missing-cluster"}, + } + + errors := validator.validateOperationUpstream(0, up, definitions) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.sandbox.ref") { + found = true + break + } + } + assert.True(t, found, "expected unknown-ref error scoped to sandbox.ref, got %+v", errors) +} + +// TestValidateOperationUpstream_RefPatternRejected asserts that a ref containing +// characters outside ^[a-zA-Z0-9\-_]+$ is rejected before the existence check. +func TestValidateOperationUpstream_RefPatternRejected(t *testing.T) { + validator := NewAPIValidator() + definitions := &[]api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + } + + for _, badRef := range []string{"bad/ref", "bad ref", "bad.ref!", "../etc"} { + up := &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: badRef}, + } + errors := validator.validateOperationUpstream(0, up, definitions) + require.NotEmpty(t, errors, "ref %q must be rejected", badRef) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main.ref") && + strings.Contains(e.Message, "must match pattern") { + found = true + break + } + } + assert.True(t, found, "expected pattern-rejection error for ref %q, got %+v", badRef, errors) + } +} + +// TestValidateOperationUpstream_RefMaxLength asserts that a ref longer than 100 +// characters is rejected, matching the OpenAPI schema maxLength constraint. +func TestValidateOperationUpstream_RefMaxLength(t *testing.T) { + validator := NewAPIValidator() + longRef := strings.Repeat("a", 101) + exactRef := strings.Repeat("b", 100) + definitions := &[]api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + {Name: longRef, Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://long-svc:8080"}}}, + {Name: exactRef, Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://exact-svc:8080"}}}, + } + + up := &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: longRef}, + } + errors := validator.validateOperationUpstream(0, up, definitions) + require.NotEmpty(t, errors, "ref longer than 100 chars must be rejected") + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main.ref") && + strings.Contains(e.Message, "must not exceed 100 characters") { + found = true + break + } + } + assert.True(t, found, "expected maxLength-rejection error for ref of len %d, got %+v", len(longRef), errors) + + // Boundary: exactly 100 characters should pass + up = &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: exactRef}, + } + errors = validator.validateOperationUpstream(0, up, definitions) + assert.Empty(t, errors, "ref of exactly 100 chars must pass") +} + +// TestValidate_PerOpRef_FullFlow exercises the complete entry path +// Validate -> validateRestData -> validateOperations -> validateOperationUpstream, +// confirming a per-op ref error surfaces from the public Validate API with the +// operation-scoped field path (not just the helper in isolation). +func TestValidate_PerOpRef_FullFlow(t *testing.T) { + validator := NewAPIValidator() + config := &api.RestAPI{ + ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "per-op-ref-api-v1.0"}, + Spec: api.APIConfigData{ + DisplayName: "PerOpRefAPI", + Version: "v1.0", + Context: "/per-op", + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: func() *string { s := "http://example.com"; return &s }()}, + }, + Operations: []api.Operation{ + { + Method: "GET", + Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "missing-cluster"}, + }, + }, + }, + }, + } + + errors := validator.Validate(config) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main.ref") && + strings.Contains(e.Message, "not found") { + found = true + break + } + } + assert.True(t, found, "expected per-op ref error via full Validate, got %+v", errors) +} + +func TestValidate_APILevelRefPatternAndLength(t *testing.T) { + validator := NewAPIValidator() + + base := func(ref string) *api.RestAPI { + r := ref + return &api.RestAPI{ + ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "api-ref-v1.0"}, + Spec: api.APIConfigData{ + DisplayName: "APIRef", + Version: "v1.0", + Context: "/api-ref", + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: api.Upstream{Ref: &r}}, + Operations: []api.Operation{{Method: "GET", Path: "/x"}}, + }, + } + } + + hasRefErr := func(errs []ValidationError, msgSub string) bool { + for _, e := range errs { + if e.Field == "spec.upstream.main.ref" && strings.Contains(e.Message, msgSub) { + return true + } + } + return false + } + + t.Run("bad pattern is rejected with a pattern error", func(t *testing.T) { + errs := validator.Validate(base("bad/ref")) + assert.True(t, hasRefErr(errs, "must match pattern"), "API-level ref with bad characters should give a pattern error, got %+v", errs) + }) + + t.Run("over-length ref is rejected with a length error", func(t *testing.T) { + errs := validator.Validate(base(strings.Repeat("a", 101))) + assert.True(t, hasRefErr(errs, "must not exceed 100 characters"), "API-level ref over 100 chars should give a length error, got %+v", errs) + }) +} diff --git a/gateway/gateway-controller/pkg/transform/llm_test.go b/gateway/gateway-controller/pkg/transform/llm_test.go new file mode 100644 index 0000000000..1eb98b0feb --- /dev/null +++ b/gateway/gateway-controller/pkg/transform/llm_test.go @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package transform + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" +) + +// TestLLMTransformer_ClusterNameKeyedOnLLMUUID verifies that an LLM config receives the +// same identity-based cluster name as a REST API. LLMTransformer converts the config to a +// RestAPI and delegates to RestAPITransformer; this pins that the LLM config's UUID is +// carried through the extra hop, so the cluster name is clusterkey.APILevelName(env, UUID) +// and not keyed on anything LLM-specific. +func TestLLMTransformer_ClusterNameKeyedOnLLMUUID(t *testing.T) { + // A RestAPI Configuration is supplied directly, so Transform uses it as-is and skips + // the provider transform; no storage backend is needed for this path. + base := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users"}, + }) + restAPI := base.Configuration.(api.RestAPI) + cfg := &models.StoredConfig{ + UUID: "test-llm-api", + Kind: "LlmProxy", + Configuration: restAPI, + } + + // Construct directly with only restTransformer set: the RestAPI-direct path does not + // use the provider transformer or storage, so this avoids an unrelated db dependency. + transformer := &LLMTransformer{ + restTransformer: NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}), + } + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute, "main route must exist") + assert.Equal(t, clusterkey.APILevelName("main", cfg.UUID), mainRoute.Upstream.ClusterKey, + "LLM cluster name must be the identity name keyed on the LLM config UUID") +} diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index 8d404dca3d..02da5de07d 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -31,6 +31,8 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/upstreamref" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" policyv1alpha "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" @@ -140,9 +142,23 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim // Determine vhosts to create routes for. // Sandbox is active when a sandbox upstream is configured via either url or ref. - hasSandbox := apiData.Upstream.Sandbox != nil && + apiSandboxHasContent := apiData.Upstream.Sandbox != nil && ((apiData.Upstream.Sandbox.Url != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Url) != "") || (apiData.Upstream.Sandbox.Ref != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Ref) != "")) + hasSandbox := apiSandboxHasContent + + // Also active if any operation defines a per-op sandbox upstream. + if !hasSandbox { + for _, op := range apiData.Operations { + if op.Upstream != nil && op.Upstream.Sandbox != nil { + sb := op.Upstream.Sandbox + if strings.TrimSpace(sb.Ref) != "" { + hasSandbox = true + break + } + } + } + } // Guard: sandbox and main vhosts must differ, otherwise sandbox routes would // overwrite main routes (same route key) and the sandbox patch would leave only @@ -153,25 +169,82 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim // Build routes and policy chains for each operation for _, op := range apiData.Operations { + mainVhostClusterKey := mainUpstream.ClusterKey + mainVhostUseClusterHeader := useClusterHeader + mainVhostDefaultCluster := defaultCluster + mainVhostAutoHostRewrite := mainAutoHostRewrite + + sandboxVhostClusterKey := mainUpstream.ClusterKey + sandboxVhostUseClusterHeader := useClusterHeader + sandboxVhostDefaultCluster := defaultCluster + // Per-op sandbox routes carry no HostRewrite; inherit the API-level sandbox + // setting when present, else the main setting (matches the xDS path). + sandboxVhostAutoHostRewrite := mainAutoHostRewrite + if apiSandboxHasContent { + sandboxVhostAutoHostRewrite = true + if apiData.Upstream.Sandbox.HostRewrite != nil && *apiData.Upstream.Sandbox.HostRewrite == api.Manual { + sandboxVhostAutoHostRewrite = false + } + } + + if op.Upstream != nil { + if op.Upstream.Main != nil { + defClusterKey, err := perOpDefinitionClusterKey(cfg.Kind, cfg.UUID, op.Upstream.Main, apiData.UpstreamDefinitions) + if err != nil { + return nil, fmt.Errorf("per-op main upstream for %s %s: %w", string(op.Method), op.Path, err) + } + // Reuse the referenced definition's cluster as the cluster_header default + // so a dynamic-endpoint policy can still steer this operation. + mainVhostClusterKey = defClusterKey + mainVhostUseClusterHeader = true + mainVhostDefaultCluster = defClusterKey + // AutoHostRewrite inherits API-level setting; per-op target is ref-only with no HostRewrite field. + } + if op.Upstream.Sandbox != nil { + defClusterKey, err := perOpDefinitionClusterKey(cfg.Kind, cfg.UUID, op.Upstream.Sandbox, apiData.UpstreamDefinitions) + if err != nil { + return nil, fmt.Errorf("per-op sandbox upstream for %s %s: %w", string(op.Method), op.Path, err) + } + // Keep cluster_header ON for the sandbox per-op route so a sandbox + // dynamic-endpoint policy can override the per-op default. + sandboxVhostClusterKey = defClusterKey + sandboxVhostUseClusterHeader = true + sandboxVhostDefaultCluster = defClusterKey + // AutoHostRewrite inherits API-level setting; per-op target is ref-only with no HostRewrite field. + } + } + vhosts := []string{effectiveMainVHost} - if hasSandbox { + // Add the sandbox vhost only when this op has sandbox config (API-level + // fallback or a per-op override); otherwise it would route to the main cluster. + if apiSandboxHasContent || (op.Upstream != nil && op.Upstream.Sandbox != nil) { vhosts = append(vhosts, effectiveSandboxVHost) } for _, vhost := range vhosts { routeKey := xds.GenerateRouteName(string(op.Method), apiData.Context, apiData.Version, op.Path, vhost) - // Build route + clusterKey := mainVhostClusterKey + vhostUseClusterHeader := mainVhostUseClusterHeader + vhostDefaultCluster := mainVhostDefaultCluster + autoHostRewrite := mainVhostAutoHostRewrite + if vhost == effectiveSandboxVHost { + clusterKey = sandboxVhostClusterKey + vhostUseClusterHeader = sandboxVhostUseClusterHeader + vhostDefaultCluster = sandboxVhostDefaultCluster + autoHostRewrite = sandboxVhostAutoHostRewrite + } + rdc.Routes[routeKey] = &models.Route{ Method: string(op.Method), Path: xds.ConstructFullPath(apiData.Context, apiData.Version, op.Path), OperationPath: op.Path, Vhost: vhost, - AutoHostRewrite: mainAutoHostRewrite, + AutoHostRewrite: autoHostRewrite, Upstream: models.RouteUpstream{ - ClusterKey: mainUpstream.ClusterKey, - UseClusterHeader: useClusterHeader, - DefaultCluster: defaultCluster, + ClusterKey: clusterKey, + UseClusterHeader: vhostUseClusterHeader, + DefaultCluster: vhostDefaultCluster, }, } @@ -188,7 +261,7 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { continue } - defClusterKey := "upstream_" + cfg.Kind + "_" + cfg.UUID + "_" + SanitizeUpstreamDefinitionName(def.Name) + defClusterKey := clusterkey.DefinitionName(cfg.Kind, cfg.UUID, def.Name) parsedURL, err := url.Parse(def.Upstreams[0].Url) if err != nil { return nil, fmt.Errorf("invalid URL in upstream definition '%s': %w", def.Name, err) @@ -212,8 +285,9 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim } } - // Add sandbox upstream and update sandbox routes if present - if hasSandbox { + // Add sandbox upstream and update sandbox routes if present. + // API-level sandbox is optional when per-op sandbox overrides exist. + if apiSandboxHasContent { sbUpstream, err := t.addUpstreamCluster(rdc, "sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) if err != nil { return nil, fmt.Errorf("failed to resolve sandbox upstream: %w", err) @@ -224,8 +298,12 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim sbAutoHostRewrite = false } - // Update sandbox vhost routes to point to sandbox cluster + // Update sandbox vhost routes to point to sandbox cluster, except ops with + // their own per-op sandbox override (already wired in the main loop). for _, op := range apiData.Operations { + if op.Upstream != nil && op.Upstream.Sandbox != nil { + continue + } routeKey := xds.GenerateRouteName(string(op.Method), apiData.Context, apiData.Version, op.Path, effectiveSandboxVHost) if r, exists := rdc.Routes[routeKey]; exists { r.Upstream.ClusterKey = sbUpstream.ClusterKey @@ -298,9 +376,8 @@ func (t *RestAPITransformer) buildPolicyChain( type upstreamClusterResult struct { // ClusterKey is the internal key used in rdc.UpstreamClusters. ClusterKey string - // EnvoyClusterName is the Envoy cluster name matching pkg/xds/translator.go's - // sanitizeClusterName format ("cluster__"). - // This is the value Envoy knows the cluster by, so PE must use it for x-target-upstream. + // EnvoyClusterName is the name Envoy knows the cluster by, used by the policy + // engine for the x-target-upstream header. It is always set equal to ClusterKey. EnvoyClusterName string // BasePath is the URL path component of the upstream (e.g. "/anything/foo"). BasePath string @@ -337,7 +414,9 @@ func (t *RestAPITransformer) addUpstreamCluster( basePath = "/" } - clusterKey := fmt.Sprintf("upstream_%s_%s_%d", upstreamName, parsedURL.Hostname(), port) + // URL-stable cluster name so a URL edit updates the same cluster instead of + // renaming it. ClusterKey and EnvoyClusterName are intentionally identical. + clusterKey := clusterkey.APILevelName(upstreamName, rdc.Metadata.UUID) rdc.UpstreamClusters[clusterKey] = &models.UpstreamCluster{ BasePath: basePath, @@ -348,19 +427,27 @@ func (t *RestAPITransformer) addUpstreamCluster( TLS: &models.UpstreamTLS{Enabled: parsedURL.Scheme == "https"}, } + // ClusterKey and EnvoyClusterName must stay identical or the default upstream + // path yields 503 NoRoute. return &upstreamClusterResult{ ClusterKey: clusterKey, - EnvoyClusterName: sanitizeEnvoyClusterName(parsedURL.Host, parsedURL.Scheme), + EnvoyClusterName: clusterKey, BasePath: basePath, }, nil } -// sanitizeEnvoyClusterName computes the Envoy cluster name from a URL host and scheme, -// matching the sanitizeClusterName logic in pkg/xds/translator.go. -func sanitizeEnvoyClusterName(host, scheme string) string { - name := strings.ReplaceAll(host, ".", "_") - name = strings.ReplaceAll(name, ":", "_") - return "cluster_" + scheme + "_" + name +// perOpDefinitionClusterKey resolves a ref-only per-op target to the cluster key of +// the referenced upstreamDefinition, reusing that definition's cluster (and its +// basePath) instead of minting a per-op one. +func perOpDefinitionClusterKey(kind, uuid string, target *api.RestAPIOperationUpstreamTarget, upstreamDefinitions *[]api.UpstreamDefinition) (string, error) { + def, err := upstreamref.FindByName(target.Ref, upstreamDefinitions) + if err != nil { + return "", err + } + if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { + return "", fmt.Errorf("upstream definition '%s' has no URLs", strings.TrimSpace(target.Ref)) + } + return clusterkey.DefinitionName(kind, uuid, def.Name), nil } // resolveUpstreamURL resolves the URL from an upstream (direct URL or ref). For a ref it @@ -372,22 +459,20 @@ func resolveUpstreamURL(name string, up *api.Upstream, defs *[]api.UpstreamDefin } if up.Ref != nil && strings.TrimSpace(*up.Ref) != "" { refName := strings.TrimSpace(*up.Ref) - if defs == nil { - return "", nil, fmt.Errorf("upstream definition '%s' referenced but no definitions provided", refName) + // Resolve via the shared upstreamref helper and return the definition's + // basePath so the caller rewrites the upstream path correctly. + def, err := upstreamref.FindByName(refName, defs) + if err != nil { + return "", nil, err } - for _, def := range *defs { - if def.Name == refName { - if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { - return "", nil, fmt.Errorf("upstream definition '%s' has no URLs", refName) - } - basePath := "" - if def.BasePath != nil { - basePath = *def.BasePath - } - return def.Upstreams[0].Url, &basePath, nil - } + if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { + return "", nil, fmt.Errorf("upstream definition '%s' has no URLs", refName) } - return "", nil, fmt.Errorf("upstream definition '%s' not found", refName) + basePath := "" + if def.BasePath != nil { + basePath = *def.BasePath + } + return def.Upstreams[0].Url, &basePath, nil } return "", nil, fmt.Errorf("%s upstream has no URL or ref", name) } @@ -406,13 +491,6 @@ func ResolvePort(u *url.URL) int { return 80 } -// SanitizeUpstreamDefinitionName replaces dots and colons for Envoy cluster name compatibility. -func SanitizeUpstreamDefinitionName(name string) string { - name = strings.ReplaceAll(name, ".", "_") - name = strings.ReplaceAll(name, ":", "_") - return name -} - // convertAPIPolicyToSDK converts an api.Policy to policyenginev1.PolicyInstance. func convertAPIPolicyToSDK(p api.Policy, attachedTo policyv1alpha.Level, resolvedVersion string) policyenginev1.PolicyInstance { paramsMap := make(map[string]interface{}) diff --git a/gateway/gateway-controller/pkg/transform/restapi_test.go b/gateway/gateway-controller/pkg/transform/restapi_test.go index 5edf451df9..4dff40bf64 100644 --- a/gateway/gateway-controller/pkg/transform/restapi_test.go +++ b/gateway/gateway-controller/pkg/transform/restapi_test.go @@ -20,6 +20,7 @@ package transform import ( "net/url" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -27,6 +28,7 @@ import ( api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" ) // ptrStr is a helper to get a pointer to a string literal. @@ -150,7 +152,7 @@ func TestRestAPITransformer_OperationLevelEmptyVersionResolvesToLatest(t *testin // TestRestAPITransformer_UnknownPolicySkipped verifies that a policy not present in // the definitions is silently excluded from the policy chain without causing an error. func TestRestAPITransformer_UnknownPolicySkipped(t *testing.T) { - defs := map[string]models.PolicyDefinition{} // empty — policy won't resolve + defs := map[string]models.PolicyDefinition{} // empty - policy won't resolve transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, defs) cfg := makeRestAPIStoredConfig( @@ -222,29 +224,6 @@ func TestRestAPITransformer_EmptyVersionUsesResolvedVersionInChain(t *testing.T) "resolved major version should be stored in the chain, not the original empty string") } -// TestSanitizeUpstreamDefinitionName verifies that dots and colons are replaced -// for Envoy cluster name compatibility. -func TestSanitizeUpstreamDefinitionName(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"my-upstream", "my-upstream"}, - {"my.upstream", "my_upstream"}, - {"my:upstream", "my_upstream"}, - {"host.example.com:8080", "host_example_com_8080"}, - {"", ""}, - {"a.b.c:d", "a_b_c_d"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := SanitizeUpstreamDefinitionName(tt.input) - assert.Equal(t, tt.expected, got) - }) - } -} - // TestResolveUpstreamURL verifies URL resolution from direct URL, ref, or missing config. func TestResolveUpstreamURL(t *testing.T) { refName := "my-def" @@ -324,6 +303,406 @@ func TestResolveUpstreamURL(t *testing.T) { }) } +// makeRestAPIWithOps builds a RestAPI StoredConfig with caller-supplied operations, +// both API-level main and sandbox upstreams configured, and a set of common +// upstreamDefinitions that per-op tests can reference by name. +func makeRestAPIWithOps(ops []api.Operation) *models.StoredConfig { + defs := []api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + {Name: "user-svc-test-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc-test:8080"}}}, + {Name: "shared-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://shared-svc:8080"}}}, + {Name: "same-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://same-svc:8080"}}}, + {Name: "user-svc-cluster-v2", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:9090"}}}, + {Name: "per-op-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://per-op-main:9090"}}}, + } + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + Operations: ops, + UpstreamDefinitions: &defs, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: ptrStr("http://api-main:8080")}, + Sandbox: &api.Upstream{Url: ptrStr("http://api-sandbox:8080")}, + }, + } + restAPI := api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "test-api"}, + Spec: apiData, + } + return &models.StoredConfig{ + UUID: "test-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: restAPI, + } +} + +// TestRestAPITransformer_PerOpMainOverridesMainVhost asserts that a main-only override +// causes the main vhost route to use the definition cluster while the sandbox vhost route +// falls back to the API-level sandbox cluster. +func TestRestAPITransformer_PerOpMainOverridesMainVhost(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-cluster"}, + }, + }, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + require.NotNil(t, rdc) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute) + assert.True(t, strings.HasPrefix(mainRoute.Upstream.ClusterKey, "upstream_"), + "main vhost should use definition cluster, got %q", mainRoute.Upstream.ClusterKey) + // Per-op main is dynamic: cluster_header ON with the definition cluster as the + // default, so a dynamic-endpoint policy can still steer it while a no-policy + // request falls back to the per-op ref. + assert.True(t, mainRoute.Upstream.UseClusterHeader, + "per-op main route should use cluster_header so policies can override") + assert.Equal(t, mainRoute.Upstream.ClusterKey, mainRoute.Upstream.DefaultCluster, + "per-op main DefaultCluster must be the definition cluster key") + + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandboxRoute) + assert.False(t, strings.HasPrefix(sandboxRoute.Upstream.ClusterKey, "upstream_"), + "sandbox vhost should fall back to API sandbox, got %q", sandboxRoute.Upstream.ClusterKey) +} + +// TestRestAPITransformer_PerOpSandboxOverridesSandboxVhost asserts that a sandbox-only override +// causes the main vhost to fall back to the API main while the sandbox vhost uses the definition cluster. +func TestRestAPITransformer_PerOpSandboxOverridesSandboxVhost(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-test-cluster"}, + }, + }, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute) + assert.False(t, strings.HasPrefix(mainRoute.Upstream.ClusterKey, "upstream_"), + "main vhost should fall back to API main, got %q", mainRoute.Upstream.ClusterKey) + + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandboxRoute) + assert.True(t, strings.HasPrefix(sandboxRoute.Upstream.ClusterKey, "upstream_"), + "sandbox vhost should use definition cluster, got %q", sandboxRoute.Upstream.ClusterKey) + // Per-op sandbox is dynamic too: cluster_header ON with the definition cluster as + // the default, mirroring the per-op main behavior so policies can still steer it. + assert.True(t, sandboxRoute.Upstream.UseClusterHeader, + "per-op sandbox route should use cluster_header so policies can override") + assert.Equal(t, sandboxRoute.Upstream.ClusterKey, sandboxRoute.Upstream.DefaultCluster, + "per-op sandbox DefaultCluster must be the definition cluster key") +} + +// TestRestAPITransformer_PerOpBothOverrideBothVhosts asserts that both vhosts get distinct +// definition clusters when main and sandbox are overridden. +func TestRestAPITransformer_PerOpBothOverrideBothVhosts(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-cluster"}, + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-test-cluster"}, + }, + }, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, mainRoute) + require.NotNil(t, sandboxRoute) + + assert.True(t, strings.HasPrefix(mainRoute.Upstream.ClusterKey, "upstream_")) + assert.True(t, strings.HasPrefix(sandboxRoute.Upstream.ClusterKey, "upstream_")) + assert.NotEqual(t, mainRoute.Upstream.ClusterKey, sandboxRoute.Upstream.ClusterKey, + "main and sandbox per-op vhosts must produce distinct cluster keys (definition names differ)") +} + +// TestRestAPITransformer_NoPerOpUsesAPILevelClusters - regression - without per-op +// upstream the routes still use the API-level main/sandbox clusters. +func TestRestAPITransformer_NoPerOpUsesAPILevelClusters(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users"}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, mainRoute) + require.NotNil(t, sandboxRoute) + assert.False(t, strings.HasPrefix(mainRoute.Upstream.ClusterKey, "upstream_")) + assert.False(t, strings.HasPrefix(sandboxRoute.Upstream.ClusterKey, "upstream_")) +} + +// TestRestAPITransformer_TwoOpsSameRefReuseOneCluster verifies the core reuse +// property: two operations referencing the SAME upstreamDefinition reuse exactly +// ONE definition cluster (no definition clusters), and both routes point at it. +func TestRestAPITransformer_TwoOpsSameRefReuseOneCluster(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users", Upstream: &api.RestAPIOperationUpstream{Main: &api.RestAPIOperationUpstreamTarget{Ref: "shared-svc"}}}, + {Method: "POST", Path: "/users", Upstream: &api.RestAPIOperationUpstream{Main: &api.RestAPIOperationUpstreamTarget{Ref: "shared-svc"}}}, + }) + spec := cfg.Configuration.(api.RestAPI) + spec.Spec.UpstreamDefinitions = &[]api.UpstreamDefinition{ + { + Name: "shared-svc", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://shared-svc:8080"}, + }, + }, + } + cfg.Configuration = spec + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + getRoute := rdc.Routes["GET|/test/users|main.local"] + postRoute := rdc.Routes["POST|/test/users|main.local"] + require.NotNil(t, getRoute, "GET route must exist") + require.NotNil(t, postRoute, "POST route must exist") + + // Both ops reuse the SAME definition cluster (no definition clusters). + assert.Equal(t, getRoute.Upstream.ClusterKey, postRoute.Upstream.ClusterKey, + "two ops sharing a ref must reuse the same definition cluster") + assert.True(t, strings.HasPrefix(getRoute.Upstream.ClusterKey, "upstream_"), + "per-op route must reuse the upstream_ definition cluster, got %q", getRoute.Upstream.ClusterKey) + + // Exactly ONE cluster registered for shared-svc; zero op_ clusters. + shared := 0 + for k := range rdc.UpstreamClusters { + assert.False(t, strings.HasPrefix(k, "op_"), "no per-op (op_) clusters may be minted, got %q", k) + if strings.Contains(k, "shared-svc") { + shared++ + } + } + assert.Equal(t, 1, shared, "shared-svc must produce exactly one reused definition cluster") +} + +// TestRestAPITransformer_PerOpClusterIsolatedAcrossAPIs asserts that two APIs with the +// same operation referencing the same definition produce different definition cluster +// keys because the API ID is part of the cluster name. +func TestRestAPITransformer_PerOpClusterIsolatedAcrossAPIs(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + cfgA := makeRestAPIWithOps([]api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "shared-svc-cluster"}, + }, + }, + }) + cfgA.UUID = "api-aaa" + + cfgB := makeRestAPIWithOps([]api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "shared-svc-cluster"}, + }, + }, + }) + cfgB.UUID = "api-bbb" + + rdcA, err := transformer.Transform(cfgA) + require.NoError(t, err) + rdcB, err := transformer.Transform(cfgB) + require.NoError(t, err) + + var keyA, keyB string + for k := range rdcA.UpstreamClusters { + if strings.HasPrefix(k, "upstream_") { + keyA = k + } + } + for k := range rdcB.UpstreamClusters { + if strings.HasPrefix(k, "upstream_") { + keyB = k + } + } + + require.NotEmpty(t, keyA) + require.NotEmpty(t, keyB) + assert.NotEqual(t, keyA, keyB, "same URL across different APIs must produce different definition cluster keys") +} + +// TestRestAPITransformer_PerOpSandboxWithoutAPILevelSandbox - guard regression. +// API-level Sandbox is nil, but one op declares a per-op sandbox upstream. The +// sandbox vhost must be created only for that op; ops without per-op sandbox +// must NOT get a sandbox route (otherwise they'd silently route to the main +// cluster on the sandbox vhost). +func TestRestAPITransformer_PerOpSandboxWithoutAPILevelSandbox(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + sbDefs := []api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc-test:8080"}}}, + } + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + UpstreamDefinitions: &sbDefs, + Operations: []api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-cluster"}, + }, + }, + {Method: "GET", Path: "/orders"}, + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: ptrStr("http://api-main:8080")}, + Sandbox: nil, + }, + } + cfg := &models.StoredConfig{ + UUID: "test-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "test-api"}, + Spec: apiData, + }, + } + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + require.NotNil(t, rdc) + + usersMain := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, usersMain, "op with per-op sandbox must still have a main route") + assert.False(t, strings.HasPrefix(usersMain.Upstream.ClusterKey, "upstream_"), + "main vhost should fall back to API main cluster, got %q", usersMain.Upstream.ClusterKey) + + usersSandbox := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, usersSandbox, "op with per-op sandbox must have a sandbox route") + assert.True(t, strings.HasPrefix(usersSandbox.Upstream.ClusterKey, "upstream_"), + "sandbox vhost should use definition cluster, got %q", usersSandbox.Upstream.ClusterKey) + + ordersMain := rdc.Routes["GET|/test/orders|main.local"] + require.NotNil(t, ordersMain, "op without per-op upstream must have a main route") + assert.False(t, strings.HasPrefix(ordersMain.Upstream.ClusterKey, "upstream_")) + + _, ordersHasSandbox := rdc.Routes["GET|/test/orders|sandbox.local"] + assert.False(t, ordersHasSandbox, + "op without per-op sandbox must NOT get a sandbox route when API-level sandbox is nil") +} + +// TestRestAPITransformer_PerOpSandboxInheritsSandboxHostRewrite - a per-op sandbox +// override route carries no HostRewrite of its own, so it must inherit the API-level +// SANDBOX HostRewrite (not the API-level main). This guards the transform/xDS parity: +// the xDS path inherits the sandbox value, so the RDC path must too. With API-level +// main=auto and sandbox=manual, the per-op sandbox route must be manual (AutoHostRewrite=false). +func TestRestAPITransformer_PerOpSandboxInheritsSandboxHostRewrite(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + manual := api.Manual + auto := api.Auto + defs := []api.UpstreamDefinition{ + {Name: "op-sandbox-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://op-sandbox:8080"}}}, + } + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + UpstreamDefinitions: &defs, + Operations: []api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "op-sandbox-cluster"}, + }, + }, + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: ptrStr("http://api-main:8080"), HostRewrite: &auto}, + Sandbox: &api.Upstream{Url: ptrStr("http://api-sandbox:8080"), HostRewrite: &manual}, + }, + } + cfg := &models.StoredConfig{ + UUID: "test-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "test-api"}, + Spec: apiData, + }, + } + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + usersSandbox := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, usersSandbox, "op with per-op sandbox must have a sandbox route") + assert.True(t, strings.HasPrefix(usersSandbox.Upstream.ClusterKey, "upstream_"), + "sandbox vhost should use definition cluster, got %q", usersSandbox.Upstream.ClusterKey) + assert.False(t, usersSandbox.AutoHostRewrite, + "per-op sandbox route must inherit API-level SANDBOX hostRewrite (manual), not main (auto)") + + usersMain := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, usersMain) + assert.True(t, usersMain.AutoHostRewrite, + "main route must keep API-level main hostRewrite (auto)") +} + // TestResolvePort checks port resolution with explicit, default-http and default-https. func TestResolvePort(t *testing.T) { tests := []struct { @@ -353,7 +732,6 @@ func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { defs := map[string]models.PolicyDefinition{} const sandboxURL = "http://sandbox-backend:9080/sandbox" const sandboxRouteKey = "GET|/test/hello|sandbox.local" - expectedSandboxCluster := sanitizeEnvoyClusterName("sandbox-backend:9080", "http") t.Run("without upstreamDefinitions the sandbox route is static", func(t *testing.T) { transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, defs) @@ -383,7 +761,167 @@ func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { r, exists := rdc.Routes[sandboxRouteKey] require.True(t, exists, "sandbox route should exist") assert.True(t, r.Upstream.UseClusterHeader) - assert.Equal(t, expectedSandboxCluster, r.Upstream.DefaultCluster, - "sandbox route must default to the sandbox cluster, not main") + assert.True(t, strings.HasPrefix(r.Upstream.DefaultCluster, "sandbox_"), + "sandbox route must default to the URL-stable sandbox cluster (sandbox_), not main; got %q", r.Upstream.DefaultCluster) }) } + +// TestRestAPITransformer_APILevelClusterNameShape asserts the URL-stable cluster +// naming contract for API-level main and sandbox upstreams: +// - cluster names are "_<24-hex>" derived from sha256(apiID), shared by main and sandbox +// - ClusterKey and EnvoyClusterName are the SAME string (so the policy engine's +// default_upstream_cluster metadata resolves to a real Envoy cluster) +func TestRestAPITransformer_APILevelClusterNameShape(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users"}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + // Expected name is hard-coded (sha256("test-api")[:12]), not computed via + // clusterkey.APILevel, so a change to the hashing function is caught here. + expectedMain := "main_2a28373e2cacc6ea903d8c7e" + expectedSandbox := "sandbox_2a28373e2cacc6ea903d8c7e" + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute, "main route must exist") + assert.Equal(t, expectedMain, mainRoute.Upstream.ClusterKey, + "main cluster name should be _ derived from sha256(apiID)") + + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandboxRoute, "sandbox route must exist") + assert.Equal(t, expectedSandbox, sandboxRoute.Upstream.ClusterKey, + "sandbox cluster name should be _ derived from sha256(apiID)") + + _, mainExists := rdc.UpstreamClusters[expectedMain] + require.True(t, mainExists, "main cluster %q must be registered in UpstreamClusters", expectedMain) + _, sandboxExists := rdc.UpstreamClusters[expectedSandbox] + require.True(t, sandboxExists, "sandbox cluster %q must be registered in UpstreamClusters", expectedSandbox) +} + +// TestRestAPITransformer_APILevelDefaultClusterMatchesRealCluster verifies that +// route.Upstream.DefaultCluster matches a cluster registered in +// rdc.UpstreamClusters whenever UseClusterHeader is enabled. The policy engine +// writes DefaultCluster into the x-target-upstream header and Envoy looks up +// the cluster by that value; if the name does not match a registered cluster, +// Envoy returns 503 NoRoute. +func TestRestAPITransformer_APILevelDefaultClusterMatchesRealCluster(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users"}, + }) + // Add an upstreamDefinition so UseClusterHeader becomes true and + // DefaultCluster is actually populated. + spec := cfg.Configuration.(api.RestAPI) + spec.Spec.UpstreamDefinitions = &[]api.UpstreamDefinition{ + { + Name: "stub-def", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://stub-def-svc:8080"}, + }, + }, + } + cfg.Configuration = spec + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute) + require.True(t, mainRoute.Upstream.UseClusterHeader, + "upstreamDefinitions present, UseClusterHeader should be true so DefaultCluster is meaningful") + require.NotEmpty(t, mainRoute.Upstream.DefaultCluster, + "DefaultCluster must be populated when UseClusterHeader is true") + + _, exists := rdc.UpstreamClusters[mainRoute.Upstream.DefaultCluster] + assert.True(t, exists, + "DefaultCluster %q must reference a real registered cluster in UpstreamClusters "+ + "(prevents 503 NoRoute when policy engine writes x-target-upstream)", + mainRoute.Upstream.DefaultCluster) + assert.Equal(t, mainRoute.Upstream.ClusterKey, mainRoute.Upstream.DefaultCluster, + "DefaultCluster and ClusterKey must be the same string") +} + +// TestRestAPITransformer_APILevelURLStableAcrossURLEdit asserts that editing the +// API-level main upstream URL does NOT change the cluster name. This is the +// URL-stable contract: the route keeps pointing at the same named cluster and +// name-keyed stats stay continuous across URL edits. +func TestRestAPITransformer_APILevelURLStableAcrossURLEdit(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + cfgA := makeRestAPIWithOps([]api.Operation{{Method: "GET", Path: "/users"}}) + rdcA, err := transformer.Transform(cfgA) + require.NoError(t, err) + + cfgB := makeRestAPIWithOps([]api.Operation{{Method: "GET", Path: "/users"}}) + specB := cfgB.Configuration.(api.RestAPI) + specB.Spec.Upstream.Main.Url = ptrStr("http://api-main-v2:9090") + cfgB.Configuration = specB + rdcB, err := transformer.Transform(cfgB) + require.NoError(t, err) + + nameA := rdcA.Routes["GET|/test/users|main.local"].Upstream.ClusterKey + nameB := rdcB.Routes["GET|/test/users|main.local"].Upstream.ClusterKey + assert.Equal(t, nameA, nameB, + "API-level main cluster name must not depend on URL "+ + "(URL-stable contract: the name must survive URL edits)") +} + +// TestRestAPITransformer_APILevelMainOnlyHasNoSandboxCluster verifies that an +// API with no sandbox upstream registers no sandbox_ cluster and creates +// no sandbox route. The optional env must not leave a route pointing at a +// cluster absent from UpstreamClusters (which would surface as 503 NoRoute). +func TestRestAPITransformer_APILevelMainOnlyHasNoSandboxCluster(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users"}, + }) + spec := cfg.Configuration.(api.RestAPI) + spec.Spec.Upstream.Sandbox = nil // main-only API + cfg.Configuration = spec + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + // Expected name is hard-coded (sha256("test-api")[:12]), not computed via + // clusterkey.APILevel, so a change to the hashing function is caught here. + expectedMain := "main_2a28373e2cacc6ea903d8c7e" + expectedSandbox := "sandbox_2a28373e2cacc6ea903d8c7e" + + _, mainExists := rdc.UpstreamClusters[expectedMain] + require.True(t, mainExists, "main cluster %q must still be registered", expectedMain) + + _, sandboxExists := rdc.UpstreamClusters[expectedSandbox] + assert.False(t, sandboxExists, + "sandbox cluster %q must not be registered when no sandbox upstream is configured", expectedSandbox) + + _, sandboxRouteExists := rdc.Routes["GET|/test/users|sandbox.local"] + assert.False(t, sandboxRouteExists, + "no sandbox route should exist for a main-only API") +} + +// TestRestAPITransformer_ClusterNameUsesSharedHelper locks the cross-builder +// naming contract: the transform path names the cluster exactly +// clusterkey.APILevelName(env, cfg.UUID), the same helper and argument the xDS +// translator uses (pinned on that side in pkg/xds tests), so the two builders +// cannot drift to different names for the same API. +func TestRestAPITransformer_ClusterNameUsesSharedHelper(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: "GET", Path: "/users"}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + assert.Equal(t, clusterkey.APILevelName("main", cfg.UUID), + rdc.Routes["GET|/test/users|main.local"].Upstream.ClusterKey) + assert.Equal(t, clusterkey.APILevelName("sandbox", cfg.UUID), + rdc.Routes["GET|/test/users|sandbox.local"].Upstream.ClusterKey) +} + diff --git a/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go new file mode 100644 index 0000000000..bbd0fca2c6 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package clusterkey produces deterministic Envoy cluster-key fragments for the +// gateway-controller, shared by both xDS builders so they name clusters identically. +package clusterkey + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" +) + +// APILevel returns the 24-hex cluster-key fragment for an API-level upstream, +// the first 12 bytes of SHA-256(apiID). The URL is excluded so the name is stable +// across URL edits; main and sandbox share it, set apart by the caller's env prefix. +func APILevel(apiID string) string { + sum := sha256.Sum256([]byte(apiID)) + return hex.EncodeToString(sum[:12]) +} + +// APILevelName joins the env prefix ("main"/"sandbox") to the APILevel fragment +// to form the full Envoy cluster name. +func APILevelName(env, apiID string) string { + return env + "_" + APILevel(apiID) +} + +// DefinitionName returns the full Envoy cluster name for an upstreamDefinition, +// formatted as "upstream___". Dots and colons in the +// definition name are replaced so the result is a valid Envoy cluster name. Both +// xDS builders use this so they name definition clusters identically. +func DefinitionName(kind, apiID, defName string) string { + return constants.UpstreamDefinitionClusterPrefix + kind + "_" + apiID + "_" + sanitizeDefName(defName) +} + +// sanitizeDefName replaces dots and colons, which are not allowed in Envoy cluster names. +func sanitizeDefName(name string) string { + name = strings.ReplaceAll(name, ".", "_") + name = strings.ReplaceAll(name, ":", "_") + return name +} diff --git a/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go new file mode 100644 index 0000000000..cad38a039e --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package clusterkey + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +// hexShape24 matches exactly 24 lowercase hex characters - the cluster-key +// fragment shape produced by APILevel. +var hexShape24 = regexp.MustCompile("^[a-f0-9]{24}$") + +// TestAPILevel validates the API-level cluster-key fragment: deterministic, +// distinct per apiID, and pinned to SHA-256[:12] (24 hex chars). +func TestAPILevel(t *testing.T) { + t.Run("deterministic for identical input", func(t *testing.T) { + a := APILevel("api-1") + b := APILevel("api-1") + assert.Equal(t, a, b, "same input must produce same hash") + assert.Regexp(t, hexShape24, a, "hash must be exactly 24 lowercase hex characters") + }) + + t.Run("different apiID produces different hash", func(t *testing.T) { + a := APILevel("api-1") + b := APILevel("api-2") + assert.NotEqual(t, a, b) + }) + + // Known-answer vectors pin the algorithm to SHA-256[:12]. Without these, any + // deterministic 24-hex function would satisfy the shape checks above. + t.Run("known-answer vectors", func(t *testing.T) { + assert.Equal(t, "f9811b73ac5d1a8db842634f", APILevel("api-1")) + assert.Equal(t, "2a28373e2cacc6ea903d8c7e", APILevel("test-api")) + // A realistic UUIDv7-shaped apiID, the form used in production. + assert.Equal(t, "54a9b3e5ce2b6ccb97168e59", APILevel("0190b3e2-7b1c-7c2a-9b3d-1a2b3c4d5e6f")) + }) + + // Empty input is deterministic (the SHA-256 of the empty string), documenting + // that APILevel itself does not reject empty apiIDs; non-emptiness is enforced + // upstream at deploy time. + t.Run("empty input is deterministic", func(t *testing.T) { + assert.Equal(t, "e3b0c44298fc1c149afbf4c8", APILevel("")) + }) +} + +// TestAPILevelName validates the full cluster-name contract: the env prefix +// joined to the APILevel fragment. Both xDS builders go through this helper, so +// the two paths cannot drift. +func TestAPILevelName(t *testing.T) { + t.Run("joins env prefix to fragment", func(t *testing.T) { + assert.Equal(t, "main_"+APILevel("api-1"), APILevelName("main", "api-1")) + assert.Equal(t, "sandbox_"+APILevel("api-1"), APILevelName("sandbox", "api-1")) + }) + + t.Run("main and sandbox share the fragment, differ by prefix", func(t *testing.T) { + main := APILevelName("main", "api-1") + sandbox := APILevelName("sandbox", "api-1") + assert.NotEqual(t, main, sandbox) + assert.Equal(t, "main_f9811b73ac5d1a8db842634f", main) + assert.Equal(t, "sandbox_f9811b73ac5d1a8db842634f", sandbox) + }) +} + +// TestDefinitionName validates the upstreamDefinition cluster-name contract: the +// "upstream_" prefix, kind and apiID scoping, and dot/colon sanitization. Both xDS +// builders go through this helper, so per-op definition cluster names cannot drift. +func TestDefinitionName(t *testing.T) { + t.Run("format and scoping", func(t *testing.T) { + assert.Equal(t, "upstream_RestApi_api-1_my-upstream", DefinitionName("RestApi", "api-1", "my-upstream")) + }) + + t.Run("sanitizes dots and colons", func(t *testing.T) { + tests := []struct { + defName string + expected string + }{ + {"my.upstream", "upstream_RestApi_api-1_my_upstream"}, + {"my:upstream", "upstream_RestApi_api-1_my_upstream"}, + {"host.example.com:8080", "upstream_RestApi_api-1_host_example_com_8080"}, + {"a.b.c:d", "upstream_RestApi_api-1_a_b_c_d"}, + } + for _, tt := range tests { + t.Run(tt.defName, func(t *testing.T) { + assert.Equal(t, tt.expected, DefinitionName("RestApi", "api-1", tt.defName)) + }) + } + }) +} diff --git a/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go new file mode 100644 index 0000000000..4022c9e7ed --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package upstreamref centralizes resolution of per-op and API-level upstream +// references against the spec.upstreamDefinitions block. Both the xDS translator +// and the RDC transformer consume the same definitions and must agree on lookup +// and timeout-parsing semantics; this package exists so they share one source of +// truth. +package upstreamref + +import ( + "fmt" + "strings" + "time" + + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" +) + +// FindByName returns the UpstreamDefinition whose Name matches ref (after +// trimming whitespace). Returns an error if ref is empty, defs is nil/empty, or +// no matching definition exists. +func FindByName(ref string, defs *[]api.UpstreamDefinition) (*api.UpstreamDefinition, error) { + refName := strings.TrimSpace(ref) + if refName == "" { + return nil, fmt.Errorf("upstream ref is empty") + } + if defs == nil || len(*defs) == 0 { + return nil, fmt.Errorf("upstream definition '%s' referenced but no definitions provided", refName) + } + for i, def := range *defs { + if strings.TrimSpace(def.Name) == refName { + return &(*defs)[i], nil + } + } + return nil, fmt.Errorf("upstream definition '%s' not found", refName) +} + +// ParseConnectTimeout parses an UpstreamTimeout.Connect string. Empty/nil input +// returns (nil, nil). A parse failure or a non-positive duration returns an +// error so xDS and RDC paths fail consistently rather than silently dropping. +func ParseConnectTimeout(timeoutStr *string) (*time.Duration, error) { + if timeoutStr == nil { + return nil, nil + } + trimmed := strings.TrimSpace(*timeoutStr) + if trimmed == "" { + return nil, nil + } + d, err := time.ParseDuration(trimmed) + if err != nil { + return nil, fmt.Errorf("invalid timeout format: %w", err) + } + if d <= 0 { + return nil, fmt.Errorf("timeout must be positive, got: %v", d) + } + return &d, nil +} diff --git a/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go new file mode 100644 index 0000000000..b52b1183c1 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package upstreamref + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" +) + +func TestFindByName_Found(t *testing.T) { + defs := &[]api.UpstreamDefinition{ + {Name: "users-svc"}, + {Name: "orders-svc"}, + } + def, err := FindByName("orders-svc", defs) + require.NoError(t, err) + require.NotNil(t, def) + assert.Equal(t, "orders-svc", def.Name) +} + +func TestFindByName_TrimsWhitespace(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + def, err := FindByName(" users-svc ", defs) + require.NoError(t, err) + assert.Equal(t, "users-svc", def.Name) +} + +func TestFindByName_EmptyRef(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + _, err := FindByName("", defs) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestFindByName_WhitespaceRef(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + _, err := FindByName(" ", defs) + require.Error(t, err) +} + +func TestFindByName_NilDefs(t *testing.T) { + _, err := FindByName("users-svc", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no definitions provided") +} + +func TestFindByName_EmptyDefs(t *testing.T) { + defs := &[]api.UpstreamDefinition{} + _, err := FindByName("users-svc", defs) + require.Error(t, err) + assert.Contains(t, err.Error(), "no definitions provided") +} + +func TestFindByName_NotFound(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + _, err := FindByName("orders-svc", defs) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestFindByName_ReturnsStablePointer(t *testing.T) { + defs := &[]api.UpstreamDefinition{ + {Name: "a"}, + {Name: "b"}, + {Name: "c"}, + } + got, err := FindByName("b", defs) + require.NoError(t, err) + assert.Same(t, &(*defs)[1], got, "must return pointer into the slice, not a copy of a loop variable") +} + +func TestParseConnectTimeout_NilInput(t *testing.T) { + d, err := ParseConnectTimeout(nil) + require.NoError(t, err) + assert.Nil(t, d) +} + +func TestParseConnectTimeout_EmptyString(t *testing.T) { + empty := "" + d, err := ParseConnectTimeout(&empty) + require.NoError(t, err) + assert.Nil(t, d) +} + +func TestParseConnectTimeout_WhitespaceOnly(t *testing.T) { + ws := " " + d, err := ParseConnectTimeout(&ws) + require.NoError(t, err) + assert.Nil(t, d) +} + +func TestParseConnectTimeout_Valid(t *testing.T) { + v := "5s" + d, err := ParseConnectTimeout(&v) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 5*time.Second, *d) +} + +func TestParseConnectTimeout_ValidMilliseconds(t *testing.T) { + v := "500ms" + d, err := ParseConnectTimeout(&v) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 500*time.Millisecond, *d) +} + +func TestParseConnectTimeout_ValidMinutesAndHours(t *testing.T) { + m := "2m" + d, err := ParseConnectTimeout(&m) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 2*time.Minute, *d) + + h := "1h" + d, err = ParseConnectTimeout(&h) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 1*time.Hour, *d) +} + +func TestParseConnectTimeout_Malformed(t *testing.T) { + v := "abc" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid timeout format") +} + +func TestParseConnectTimeout_NoUnit(t *testing.T) { + v := "30" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid timeout format") +} + +func TestParseConnectTimeout_Zero(t *testing.T) { + v := "0s" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be positive") +} + +func TestParseConnectTimeout_Negative(t *testing.T) { + v := "-5s" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be positive") +} diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index 408a6dda7a..41e1847be1 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -32,6 +32,8 @@ import ( "time" commonconstants "github.com/wso2/api-platform/common/constants" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/upstreamref" accesslog "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3" cluster "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" @@ -767,7 +769,7 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* clusters := []*cluster.Cluster{} // -------- MAIN UPSTREAM -------- - mainClusterName, parsedMainURL, mainTimeout, err := t.resolveUpstreamCluster("main", &apiData.Upstream.Main, apiData.UpstreamDefinitions) + mainClusterName, parsedMainURL, mainTimeout, err := t.resolveUpstreamCluster(cfg.UUID, "main", &apiData.Upstream.Main, apiData.UpstreamDefinitions) if err != nil { return nil, nil, err } @@ -823,35 +825,103 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* // When upstreamDefinitions exist, use cluster_header routing so policies can select the upstream useClusterHeader := apiData.UpstreamDefinitions != nil && len(*apiData.UpstreamDefinitions) > 0 + routeClusterName := mainClusterName + routeURLPath := parsedMainURL.Path + routeHostRewrite := apiData.Upstream.Main.HostRewrite + routeTimeout := mainTimeout + routeUseClusterHeader := useClusterHeader + if op.Upstream != nil && op.Upstream.Main != nil { + defClusterName, defBasePath, defTimeout, err := t.resolvePerOpDefinitionCluster(cfg.Kind, cfg.UUID, op.Upstream.Main, apiData.UpstreamDefinitions) + if err != nil { + return nil, nil, fmt.Errorf("per-op main upstream for %s %s: %w", string(op.Method), op.Path, err) + } + // Reuse the referenced definition's cluster (routeURLPath carries its base + // path) and keep cluster_header ON with that cluster as the default so a + // dynamic-endpoint policy can still steer this operation. + routeClusterName = defClusterName + routeURLPath = defBasePath + routeTimeout = defTimeout + routeUseClusterHeader = true + } + r := t.createRoute(cfg.UUID, apiData.DisplayName, apiData.Version, apiData.Context, string(op.Method), op.Path, - mainClusterName, parsedMainURL.Path, effectiveMainVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Main.HostRewrite, apiProjectID, mainTimeout, useClusterHeader, upstreamDefPaths) + routeClusterName, routeURLPath, effectiveMainVHost, cfg.Kind, templateHandle, providerName, routeHostRewrite, apiProjectID, routeTimeout, routeUseClusterHeader, upstreamDefPaths) mainRoutesList = append(mainRoutesList, r) } routesList = append(routesList, mainRoutesList...) // -------- SANDBOX UPSTREAM -------- - if apiData.Upstream.Sandbox != nil { - sbClusterName, parsedSbURL, sbTimeout, err := t.resolveUpstreamCluster("sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) - if err != nil { - return nil, nil, err + hasSandbox := apiData.Upstream.Sandbox != nil + if !hasSandbox { + for _, op := range apiData.Operations { + if op.Upstream != nil && op.Upstream.Sandbox != nil { + sb := op.Upstream.Sandbox + if strings.TrimSpace(sb.Ref) != "" { + hasSandbox = true + break + } + } } + } + if hasSandbox { + var sbClusterName string + var parsedSbURL *url.URL + var sbTimeout *resolvedTimeout + var sbRouteHostRewrite *api.UpstreamHostRewrite - // Timeout for sandbox upstream cluster - var sbUpstreamClusterConnectTimeout *time.Duration - if sbTimeout != nil { - sbUpstreamClusterConnectTimeout = sbTimeout.Connect - } + if apiData.Upstream.Sandbox != nil { + sbClusterName, parsedSbURL, sbTimeout, err = t.resolveUpstreamCluster(cfg.UUID, "sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) + if err != nil { + return nil, nil, err + } - sandboxCluster := t.createCluster(sbClusterName, parsedSbURL, nil, sbUpstreamClusterConnectTimeout) - clusters = append(clusters, sandboxCluster) + // Timeout for sandbox upstream cluster + var sbUpstreamClusterConnectTimeout *time.Duration + if sbTimeout != nil { + sbUpstreamClusterConnectTimeout = sbTimeout.Connect + } - // Create sandbox routes. When upstreamDefinitions exist, enable dynamic cluster - // selection (mirrors main). + sandboxCluster := t.createCluster(sbClusterName, parsedSbURL, nil, sbUpstreamClusterConnectTimeout) + clusters = append(clusters, sandboxCluster) + sbRouteHostRewrite = apiData.Upstream.Sandbox.HostRewrite + } else { + // Sandbox active via per-op only: inherit API-level main HostRewrite so per-op sandbox + // routes behave consistently with main routes. + sbRouteHostRewrite = apiData.Upstream.Main.HostRewrite + } + + // Create sandbox routes. With upstreamDefinitions, enable dynamic cluster + // selection (mirrors main); a per-op sandbox ref reuses its definition cluster. sbRoutesList := make([]*route.Route, 0) sbUseClusterHeader := apiData.UpstreamDefinitions != nil && len(*apiData.UpstreamDefinitions) > 0 for _, op := range apiData.Operations { + // Skip ops without per-op sandbox when there's no API-level sandbox + if apiData.Upstream.Sandbox == nil && (op.Upstream == nil || op.Upstream.Sandbox == nil) { + continue + } + + sbRouteCluster := sbClusterName + sbRouteURLPath := "" + if parsedSbURL != nil { + sbRouteURLPath = parsedSbURL.Path + } + sbRouteHR := sbRouteHostRewrite + sbRouteTimeout := sbTimeout + sbRouteUseClusterHeader := sbUseClusterHeader + if op.Upstream != nil && op.Upstream.Sandbox != nil { + defClusterName, defBasePath, defTimeout, err := t.resolvePerOpDefinitionCluster(cfg.Kind, cfg.UUID, op.Upstream.Sandbox, apiData.UpstreamDefinitions) + if err != nil { + return nil, nil, fmt.Errorf("per-op sandbox upstream for %s %s: %w", string(op.Method), op.Path, err) + } + // Reuse the referenced definition's cluster (routeURLPath carries its base + // path) and keep cluster_header ON for sandbox dynamic-endpoint overrides. + sbRouteCluster = defClusterName + sbRouteURLPath = defBasePath + sbRouteTimeout = defTimeout + sbRouteUseClusterHeader = true + } r := t.createRoute(cfg.UUID, apiData.DisplayName, apiData.Version, apiData.Context, string(op.Method), op.Path, - sbClusterName, parsedSbURL.Path, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Sandbox.HostRewrite, apiProjectID, sbTimeout, sbUseClusterHeader, upstreamDefPaths) + sbRouteCluster, sbRouteURLPath, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, sbRouteHR, apiProjectID, sbRouteTimeout, sbRouteUseClusterHeader, upstreamDefPaths) sbRoutesList = append(sbRoutesList, r) } routesList = append(routesList, sbRoutesList...) @@ -866,13 +936,9 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* return nil, nil, fmt.Errorf("upstream definition '%s' has no URLs configured", def.Name) } - // Sanitize definition name for use in Envoy cluster name - // Envoy cluster names must not contain dots or colons - sanitizedDefName := sanitizeUpstreamDefinitionName(def.Name) - // Use the definition name as cluster name, scoped by kind and API ID to avoid conflicts // Format: upstream___ - defClusterName := constants.UpstreamDefinitionClusterPrefix + cfg.Kind + "_" + cfg.UUID + "_" + sanitizedDefName + defClusterName := clusterkey.DefinitionName(cfg.Kind, cfg.UUID, def.Name) // Parse the first URL from the definition rawURL := def.Upstreams[0].Url @@ -913,8 +979,9 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* } // resolveUpstreamCluster validates an upstream (main or sandbox) and creates its cluster. -// Returns clusterName, parsedURL, timeout (can be nil), and error. -func (t *Translator) resolveUpstreamCluster(upstreamName string, up *api.Upstream, upstreamDefinitions *[]api.UpstreamDefinition) (string, *url.URL, *resolvedTimeout, error) { +// Returns clusterName, parsedURL, timeout (can be nil), and error. The cluster name is +// "_", URL-stable for the API's lifetime. +func (t *Translator) resolveUpstreamCluster(apiID, upstreamName string, up *api.Upstream, upstreamDefinitions *[]api.UpstreamDefinition) (string, *url.URL, *resolvedTimeout, error) { var rawURL string var timeout *resolvedTimeout var refBasePath *string @@ -974,12 +1041,45 @@ func (t *Translator) resolveUpstreamCluster(upstreamName string, up *api.Upstrea parsedURL.Path = *refBasePath } - // Generate cluster name - clusterName := t.sanitizeClusterName(parsedURL.Host, parsedURL.Scheme) + // Generate cluster name from URL-stable hash (URL intentionally excluded). + clusterName := clusterkey.APILevelName(upstreamName, apiID) return clusterName, parsedURL, timeout, nil } +// resolvePerOpDefinitionCluster resolves a ref-only per-op target to the EXISTING +// upstreamDefinition cluster (created for every definition) and its base path, so a +// per-op route reuses that cluster instead of minting its own. +func (t *Translator) resolvePerOpDefinitionCluster(kind, apiID string, target *api.RestAPIOperationUpstreamTarget, upstreamDefinitions *[]api.UpstreamDefinition) (string, string, *resolvedTimeout, error) { + refName := strings.TrimSpace(target.Ref) + if refName == "" { + return "", "", nil, fmt.Errorf("per-op upstream ref is empty") + } + definition, err := resolveUpstreamDefinition(refName, upstreamDefinitions) + if err != nil { + return "", "", nil, fmt.Errorf("failed to resolve per-op upstream ref: %w", err) + } + if len(definition.Upstreams) == 0 || definition.Upstreams[0].Url == "" { + return "", "", nil, fmt.Errorf("upstream definition '%s' has no URLs configured", refName) + } + + var timeout *resolvedTimeout + if definition.Timeout != nil { + resolved, err := resolveTimeoutFromDefinition(definition) + if err != nil { + return "", "", nil, fmt.Errorf("invalid timeout in upstream definition '%s': %w", refName, err) + } + timeout = resolved + } + + basePath := "/" + if definition.BasePath != nil && *definition.BasePath != "" { + basePath = *definition.BasePath + } + clusterName := clusterkey.DefinitionName(kind, apiID, definition.Name) + return clusterName, basePath, timeout, nil +} + // SharedRouteConfigName is the name of the shared route configuration used by both HTTP and HTTPS listeners const SharedRouteConfigName = "shared_route_config" @@ -2704,22 +2804,6 @@ func (t *Translator) pathToRegex(path string) string { return "^" + regex + "$" } -// sanitizeClusterName creates a valid cluster name from a hostname and scheme -func (t *Translator) sanitizeClusterName(hostname, scheme string) string { - name := strings.ReplaceAll(hostname, ".", "_") - name = strings.ReplaceAll(name, ":", "_") - // Include scheme to differentiate HTTP and HTTPS clusters for the same host - return "cluster_" + scheme + "_" + name -} - -// sanitizeUpstreamDefinitionName sanitizes an upstream definition name for use in Envoy cluster names. -// Envoy cluster names cannot contain dots or colons. -func sanitizeUpstreamDefinitionName(name string) string { - sanitized := strings.ReplaceAll(name, ".", "_") - sanitized = strings.ReplaceAll(sanitized, ":", "_") - return sanitized -} - // createAccessLogConfig creates access log configuration based on format (JSON or text) to stdout func (t *Translator) createAccessLogConfig() ([]*accesslog.AccessLog, error) { var accessLogs []*accesslog.AccessLog @@ -2999,39 +3083,16 @@ func (t *Translator) createExtProcFilter() (*hcm.HttpFilter, error) { }, nil } -// resolveUpstreamDefinition finds an upstream definition by its reference name -// Returns the upstream definition and error if not found +// resolveUpstreamDefinition finds an upstream definition by its reference name. +// Thin wrapper over upstreamref.FindByName to keep callers in this file unchanged. func resolveUpstreamDefinition(ref string, definitions *[]api.UpstreamDefinition) (*api.UpstreamDefinition, error) { - if definitions == nil { - return nil, fmt.Errorf("upstream definition '%s' not found: no definitions provided", ref) - } - - for _, def := range *definitions { - if def.Name == ref { - return &def, nil - } - } - - return nil, fmt.Errorf("upstream definition '%s' not found", ref) + return upstreamref.FindByName(ref, definitions) } -// parseTimeout parses a duration string (e.g., "30s", "1m", "500ms") and returns a time.Duration. -// Returns nil if the input is nil or empty. +// parseTimeout parses a duration string and returns a time.Duration. Thin wrapper +// over upstreamref.ParseConnectTimeout so xDS timeout parsing uses the shared parser. func parseTimeout(timeoutStr *string) (*time.Duration, error) { - if timeoutStr == nil || strings.TrimSpace(*timeoutStr) == "" { - return nil, nil - } - - duration, err := time.ParseDuration(strings.TrimSpace(*timeoutStr)) - if err != nil { - return nil, fmt.Errorf("invalid timeout format: %w", err) - } - - if duration <= 0 { - return nil, fmt.Errorf("timeout must be positive, got: %v", duration) - } - - return &duration, nil + return upstreamref.ParseConnectTimeout(timeoutStr) } // resolveTimeoutFromDefinition converts an UpstreamDefinition's timeout block into a resolvedTimeout. diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index dc637583e8..13fff31b22 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -40,9 +40,12 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" ) -func TestResolveUpstreamDefinition_Found(t *testing.T) { +// TestResolveUpstreamDefinition_DelegatesToFindByName is a thin wiring check; +// the lookup logic itself is covered by upstreamref.TestFindByName_*. +func TestResolveUpstreamDefinition_DelegatesToFindByName(t *testing.T) { definitions := &[]api.UpstreamDefinition{ { Name: "test-upstream", @@ -58,102 +61,25 @@ func TestResolveUpstreamDefinition_Found(t *testing.T) { } def, err := resolveUpstreamDefinition("test-upstream", definitions) - require.NoError(t, err) - assert.NotNil(t, def) + require.NotNil(t, def) assert.Equal(t, "test-upstream", def.Name) -} - -func TestResolveUpstreamDefinition_NotFound(t *testing.T) { - definitions := &[]api.UpstreamDefinition{ - { - Name: "existing-upstream", - Upstreams: []struct { - Url string `json:"url" yaml:"url"` - Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` - }{ - { - Url: "http://backend:8080", - }, - }, - }, - } - - def, err := resolveUpstreamDefinition("0000-non-existent-0000-000000000000", definitions) - - assert.Error(t, err) - assert.Nil(t, def) - assert.Contains(t, err.Error(), "upstream definition '0000-non-existent-0000-000000000000' not found") -} - -func TestResolveUpstreamDefinition_NoDefinitions(t *testing.T) { - def, err := resolveUpstreamDefinition("test-upstream", nil) + _, err = resolveUpstreamDefinition("0000-non-existent-0000-000000000000", definitions) assert.Error(t, err) - assert.Nil(t, def) - assert.Contains(t, err.Error(), "no definitions provided") } -func TestParseTimeout_Valid(t *testing.T) { - tests := []struct { - name string - input string - expected time.Duration - }{ - { - name: "seconds", - input: "30s", - expected: 30 * time.Second, - }, - { - name: "minutes", - input: "2m", - expected: 2 * time.Minute, - }, - { - name: "milliseconds", - input: "500ms", - expected: 500 * time.Millisecond, - }, - { - name: "hours", - input: "1h", - expected: 1 * time.Hour, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - duration, err := parseTimeout(&tt.input) - - require.NoError(t, err) - require.NotNil(t, duration) - assert.Equal(t, tt.expected, *duration) - }) - } -} - -func TestParseTimeout_Invalid(t *testing.T) { - invalid := "invalid" - duration, err := parseTimeout(&invalid) - - assert.Error(t, err) - assert.Nil(t, duration) - assert.Contains(t, err.Error(), "invalid timeout format") -} - -func TestParseTimeout_Nil(t *testing.T) { - duration, err := parseTimeout(nil) - - assert.NoError(t, err) - assert.Nil(t, duration) -} - -func TestParseTimeout_Empty(t *testing.T) { - empty := "" - duration, err := parseTimeout(&empty) +// TestParseTimeout_DelegatesToSharedParser is a thin wiring check; the parsing +// logic itself is covered by upstreamref.TestParseConnectTimeout_*. +func TestParseTimeout_DelegatesToSharedParser(t *testing.T) { + valid := "30s" + duration, err := parseTimeout(&valid) + require.NoError(t, err) + require.NotNil(t, duration) + assert.Equal(t, 30*time.Second, *duration) - assert.NoError(t, err) + duration, err = parseTimeout(nil) + require.NoError(t, err) assert.Nil(t, duration) } @@ -164,10 +90,11 @@ func TestResolveUpstreamCluster_WithDirectURL(t *testing.T) { Url: &url, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("main", upstream, nil) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) require.NoError(t, err) - assert.Equal(t, "cluster_http_backend_8080", clusterName) + assert.Equal(t, "main_"+clusterkey.APILevel("test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Equal(t, "http", parsedURL.Scheme) assert.Equal(t, "backend:8080", parsedURL.Host) @@ -201,10 +128,11 @@ func TestResolveUpstreamCluster_WithRef_WithTimeout(t *testing.T) { }, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("main", upstream, definitions) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) require.NoError(t, err) - assert.Equal(t, "cluster_http_backend-1_9000", clusterName) + assert.Equal(t, "main_"+clusterkey.APILevel("test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Equal(t, "http", parsedURL.Scheme) assert.Equal(t, "backend-1:9000", parsedURL.Host) @@ -234,14 +162,51 @@ func TestResolveUpstreamCluster_WithRef_NoTimeout(t *testing.T) { }, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("main", upstream, definitions) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) require.NoError(t, err) - assert.Equal(t, "cluster_http_backend_8080", clusterName) + assert.Equal(t, "main_"+clusterkey.APILevel("test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Nil(t, timeout, "No timeout in definition should result in nil timeout") } +// A per-operation ref reuses the referenced upstreamDefinition's cluster and +// inherits that definition's connect timeout. This asserts the timeout flows +// through the per-op resolution path specifically (not just the API-level path). +func TestResolvePerOpDefinitionCluster_InheritsDefinitionTimeout(t *testing.T) { + translator := &Translator{} + timeoutStr := "45s" + basePath := "/v2" + target := &api.RestAPIOperationUpstreamTarget{Ref: "my-svc"} + definitions := &[]api.UpstreamDefinition{ + { + Name: "my-svc", + BasePath: &basePath, + Timeout: &api.UpstreamTimeout{ + Connect: &timeoutStr, + }, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://backend-1:9000"}, + }, + }, + } + + clusterName, defBasePath, timeout, err := translator.resolvePerOpDefinitionCluster("RestApi", "test-api", target, definitions) + + require.NoError(t, err) + assert.Equal(t, constants.UpstreamDefinitionClusterPrefix+"RestApi_test-api_my-svc", clusterName, + "per-op route should reuse the upstream-definition cluster") + assert.Equal(t, "/v2", defBasePath, "per-op route inherits the definition basePath") + require.NotNil(t, timeout) + require.NotNil(t, timeout.Connect) + assert.Equal(t, 45*time.Second, *timeout.Connect, + "per-op ref must inherit the referenced definition's connect timeout") +} + func TestResolveUpstreamCluster_WithRef_NotFound(t *testing.T) { translator := &Translator{} ref := "0000-non-existent-0000-000000000000" @@ -262,7 +227,7 @@ func TestResolveUpstreamCluster_WithRef_NotFound(t *testing.T) { }, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, definitions) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to resolve main upstream ref") @@ -293,7 +258,7 @@ func TestResolveUpstreamCluster_WithRef_InvalidTimeout(t *testing.T) { }, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, definitions) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid timeout in upstream definition") @@ -315,7 +280,7 @@ func TestResolveUpstreamCluster_WithRef_NoURLs(t *testing.T) { }, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, definitions) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) assert.Error(t, err) assert.Contains(t, err.Error(), "has no URLs configured") @@ -325,7 +290,7 @@ func TestResolveUpstreamCluster_NoURLOrRef(t *testing.T) { translator := &Translator{} upstream := &api.Upstream{} - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, nil) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) assert.Error(t, err) assert.Contains(t, err.Error(), "no main upstream configured") @@ -338,7 +303,7 @@ func TestResolveUpstreamCluster_InvalidURL(t *testing.T) { Url: &invalidURL, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, nil) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid main upstream URL") @@ -738,52 +703,6 @@ func TestTranslator_WildcardUpstreamRewriteFromRDC(t *testing.T) { } } -func TestTranslator_SanitizeClusterName(t *testing.T) { - logger := createTestLogger() - routerCfg := testRouterConfig() - cfg := testConfig() - translator := NewTranslator(logger, routerCfg, nil, cfg) - - tests := []struct { - name string - hostname string - scheme string - expected string - }{ - { - name: "Simple hostname HTTP", - hostname: "localhost", - scheme: "http", - expected: "cluster_http_localhost", - }, - { - name: "Dotted hostname HTTPS", - hostname: "api.example.com", - scheme: "https", - expected: "cluster_https_api_example_com", - }, - { - name: "Hostname with port", - hostname: "localhost:8080", - scheme: "http", - expected: "cluster_http_localhost_8080", - }, - { - name: "Complex hostname", - hostname: "api.v1.prod.example.com:443", - scheme: "https", - expected: "cluster_https_api_v1_prod_example_com_443", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := translator.sanitizeClusterName(tt.hostname, tt.scheme) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestGetValueFromSourceConfig(t *testing.T) { tests := []struct { name string @@ -1488,25 +1407,9 @@ func TestTranslator_CreateUpstreamTLSContext(t *testing.T) { assert.Equal(t, "secure.example.com", tlsContextWithCert.Sni) } -func TestTranslator_ResolveUpstreamCluster_SimpleURL(t *testing.T) { - logger := createTestLogger() - routerCfg := testRouterConfig() - cfg := testConfig() - translator := NewTranslator(logger, routerCfg, nil, cfg) - - urlStr := "http://backend:8080" - upstream := &api.Upstream{ - Url: &urlStr, - } - - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-upstream", upstream, nil) - assert.NoError(t, err) - assert.NotEmpty(t, clusterName) - assert.NotNil(t, parsedURL) - assert.Nil(t, timeout) - assert.Equal(t, "backend", parsedURL.Hostname()) -} - +// TestTranslator_ResolveUpstreamCluster_HTTPSUrl covers the HTTPS scheme; the +// http and missing-URL cases are covered by TestResolveUpstreamCluster_WithDirectURL +// and TestResolveUpstreamCluster_NoURLOrRef. func TestTranslator_ResolveUpstreamCluster_HTTPSUrl(t *testing.T) { logger := createTestLogger() routerCfg := testRouterConfig() @@ -1518,7 +1421,7 @@ func TestTranslator_ResolveUpstreamCluster_HTTPSUrl(t *testing.T) { Url: &urlStr, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("secure-upstream", upstream, nil) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "secure-upstream", upstream, nil) assert.NoError(t, err) assert.NotEmpty(t, clusterName) assert.NotNil(t, parsedURL) @@ -1526,21 +1429,6 @@ func TestTranslator_ResolveUpstreamCluster_HTTPSUrl(t *testing.T) { assert.Equal(t, "https", parsedURL.Scheme) } -func TestTranslator_ResolveUpstreamCluster_MissingURL(t *testing.T) { - logger := createTestLogger() - routerCfg := testRouterConfig() - cfg := testConfig() - translator := NewTranslator(logger, routerCfg, nil, cfg) - - upstream := &api.Upstream{ - Url: nil, // No URL - } - - _, _, _, err := translator.resolveUpstreamCluster("no-url-upstream", upstream, nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no no-url-upstream upstream configured") -} - func strPtr(s string) *string { return &s } @@ -2163,3 +2051,225 @@ func TestTranslator_CreateDynamicFwdListenerForWebSubHub(t *testing.T) { assert.Equal(t, core.SocketAddress_TCP, listener.GetAddress().GetSocketAddress().GetProtocol()) }) } + +// TestResolveUpstreamCluster_DedupSameAPIDifferentURLs asserts the URL-stable +// contract at the API level. Two distinct URLs that share the same apiID and +// env must resolve to the same cluster name, so a URL edit updates the same +// named cluster instead of removing one cluster name and adding another. +func TestResolveUpstreamCluster_DedupSameAPIDifferentURLs(t *testing.T) { + translator := &Translator{} + a := &api.Upstream{Url: strPtr("http://api-main:8080")} + b := &api.Upstream{Url: strPtr("http://api-main:9090")} + + nameA, _, _, err := translator.resolveUpstreamCluster("test-api", "main", a, nil) + require.NoError(t, err) + nameB, _, _, err := translator.resolveUpstreamCluster("test-api", "main", b, nil) + require.NoError(t, err) + + assert.Equal(t, nameA, nameB, + "API-level cluster name must not depend on URL - same API and env must produce the same cluster") +} + +// TestResolveUpstreamCluster_MainSandboxNeverCollide proves env separation: +// the same apiID with env=main vs env=sandbox must produce distinct cluster +// names so both vhosts can coexist. The names share the hash fragment (same +// API, so an operator can pair them at a glance); the env prefix provides +// the distinction. +func TestResolveUpstreamCluster_MainSandboxNeverCollide(t *testing.T) { + translator := &Translator{} + up := &api.Upstream{Url: strPtr("http://api-main:8080")} + + mainName, _, _, err := translator.resolveUpstreamCluster("test-api", "main", up, nil) + require.NoError(t, err) + sandboxName, _, _, err := translator.resolveUpstreamCluster("test-api", "sandbox", up, nil) + require.NoError(t, err) + + assert.NotEqual(t, mainName, sandboxName, + "main and sandbox cluster names must differ (the env prefix distinguishes them)") + assert.Equal(t, strings.TrimPrefix(mainName, "main_"), strings.TrimPrefix(sandboxName, "sandbox_"), + "main and sandbox must share the hash fragment so an API's cluster pair is correlatable") +} + +// TestResolveUpstreamCluster_NameNotURLDerived locks the move off the old +// URL-sanitized scheme: the cluster name must carry no URL information (no +// "cluster_" prefix, no host), only the env-prefixed identity hash. A +// regression to URL-derived naming would reintroduce connection draining on +// URL edits. +func TestResolveUpstreamCluster_NameNotURLDerived(t *testing.T) { + translator := &Translator{} + upstream := &api.Upstream{Url: strPtr("http://api.example.com:8080/v1")} + + name, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) + require.NoError(t, err) + + assert.Equal(t, "main_"+clusterkey.APILevel("test-api"), name) + assert.False(t, strings.HasPrefix(name, "cluster_"), + "cluster name must not use the old URL-derived scheme") + assert.NotContains(t, name, "api.example.com", + "cluster name must not contain the backend host") +} + +// TestTranslateConfigs_PerOpSandboxClusterEmitted asserts that the legacy path +// emits a dedicated per-op sandbox cluster and routes the sandbox vhost to it +// when an operation declares a per-op sandbox upstream override. +func TestTranslateConfigs_PerOpSandboxClusterEmitted(t *testing.T) { + translator := createTestTranslator() + + sbVhost := "sandbox.local" + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "v1.0", + Vhosts: &struct { + Main string `json:"main" yaml:"main"` + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: "localhost", + Sandbox: &sbVhost, + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: strPtr("http://api-main:8080")}, + }, + UpstreamDefinitions: &[]api.UpstreamDefinition{ + {Name: "user-svc-sb-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc-sb:8080"}}}, + }, + Operations: []api.Operation{ + { + Method: "GET", Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "user-svc-sb-cluster"}, + }, + }, + }, + } + cfg := &models.StoredConfig{ + UUID: "sandbox-op-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "sandbox-op-api"}, + Spec: apiData, + }, + } + + resources, err := translator.TranslateConfigs([]*models.StoredConfig{cfg}, "test-correlation") + require.NoError(t, err) + require.NotNil(t, resources) + + clusters := resources[resource.ClusterType] + routeConfigs := resources[resource.RouteType] + require.NotEmpty(t, clusters, "expected at least one cluster") + require.NotEmpty(t, routeConfigs, "expected at least one route configuration") + + // Per-op sandbox now REUSES the referenced definition's cluster + // (upstream___user-svc-sb-cluster); no per-op "op_" cluster is minted. + var defClusterName string + for _, c := range clusters { + name := c.(*cluster.Cluster).GetName() + require.False(t, strings.HasPrefix(name, "op_"), + "per-op refs must not mint op_ clusters anymore; got %q", name) + if strings.HasPrefix(name, "upstream_") && strings.Contains(name, "user-svc-sb-cluster") { + defClusterName = name + } + } + require.NotEmpty(t, defClusterName, + "expected the referenced upstreamDefinition cluster (upstream_..._user-svc-sb-cluster) to be emitted for reuse") + require.NotEmpty(t, routeConfigs, "expected sandbox route configuration to exist") +} + +// TestTranslateConfigs_PerOpMainReusesDefinitionCluster asserts that a per-op main +// override reuses the referenced upstreamDefinition cluster on the legacy xDS path, +// minting no per-op "op_" cluster. +func TestTranslateConfigs_PerOpMainReusesDefinitionCluster(t *testing.T) { + translator := createTestTranslator() + + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "v1.0", + Vhosts: &struct { + Main string `json:"main" yaml:"main"` + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: "localhost", + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: strPtr("http://api-main:8080")}, + }, + UpstreamDefinitions: &[]api.UpstreamDefinition{ + {Name: "premium-svc", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://premium-svc:8080"}}}, + }, + Operations: []api.Operation{ + { + Method: "GET", Path: "/premium", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "premium-svc"}, + }, + }, + }, + } + cfg := &models.StoredConfig{ + UUID: "main-op-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "main-op-api"}, + Spec: apiData, + }, + } + + resources, err := translator.TranslateConfigs([]*models.StoredConfig{cfg}, "test-correlation") + require.NoError(t, err) + require.NotNil(t, resources) + + clusters := resources[resource.ClusterType] + require.NotEmpty(t, clusters, "expected at least one cluster") + + var defClusterName string + for _, c := range clusters { + name := c.(*cluster.Cluster).GetName() + require.False(t, strings.HasPrefix(name, "op_"), + "per-op refs must not mint op_ clusters; got %q", name) + if strings.HasPrefix(name, "upstream_") && strings.Contains(name, "premium-svc") { + defClusterName = name + } + } + require.NotEmpty(t, defClusterName, + "expected the referenced upstreamDefinition cluster (upstream_..._premium-svc) to be emitted for the per-op main route") + + // The per-op main route (/premium) must be wired for cluster_header dynamic + // routing (so a dynamic-endpoint policy can still steer it), not a static cluster. + routeConfigs := resources[resource.RouteType] + require.NotEmpty(t, routeConfigs, "expected at least one route configuration") + premiumRouteFound := false + for _, rc := range routeConfigs { + for _, vh := range rc.(*route.RouteConfiguration).GetVirtualHosts() { + for _, rt := range vh.GetRoutes() { + if !strings.Contains(rt.GetMatch().GetSafeRegex().GetRegex(), "premium") { + continue + } + premiumRouteFound = true + ra := rt.GetRoute() + require.NotNil(t, ra, "per-op /premium route must have a route action") + ch, ok := ra.ClusterSpecifier.(*route.RouteAction_ClusterHeader) + require.True(t, ok, + "per-op /premium route must use cluster_header dynamic routing, not a static cluster") + assert.Equal(t, constants.TargetUpstreamHeader, ch.ClusterHeader, + "per-op /premium route must route via the target-upstream cluster header") + } + } + } + assert.True(t, premiumRouteFound, "expected to find the per-op /premium route") +} diff --git a/gateway/gateway-controller/tests/integration/storage_test.go b/gateway/gateway-controller/tests/integration/storage_test.go index 068a2eba34..4c5b23b1c2 100644 --- a/gateway/gateway-controller/tests/integration/storage_test.go +++ b/gateway/gateway-controller/tests/integration/storage_test.go @@ -798,3 +798,88 @@ func TestSQLiteStorage_LabelsPersistence(t *testing.T) { assert.Equal(t, labels, retrieved, "Loaded labels should match persisted labels") }) } + +func TestSQLiteStorage_PerOpUpstreamRefRoundtrip(t *testing.T) { + db, _, cleanup := setupTestDB(t) + defer cleanup() + + apiURL := "http://api-main:9080" + apiConfig := api.RestAPI{ + ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "PerOpRefAPI-v1.0"}, + Spec: api.APIConfigData{ + DisplayName: "PerOpRefAPI", + Version: "v1.0", + Context: "/per-op-ref", + UpstreamDefinitions: &[]api.UpstreamDefinition{ + { + Name: "users-svc", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://users-backend:9080"}}, + }, + { + Name: "users-sandbox-svc", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://users-sandbox:9080"}}, + }, + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: &apiURL}, + }, + Operations: []api.Operation{ + { + Method: api.OperationMethodGET, + Path: "/users", + Upstream: &api.RestAPIOperationUpstream{ + Main: &api.RestAPIOperationUpstreamTarget{Ref: "users-svc"}, + Sandbox: &api.RestAPIOperationUpstreamTarget{Ref: "users-sandbox-svc"}, + }, + }, + }, + }, + } + + cfg := &models.StoredConfig{ + UUID: uuid.New().String(), + Kind: string(api.RestAPIKindRestApi), + Handle: "PerOpRefAPI-v1.0", + DisplayName: "PerOpRefAPI", + Version: "v1.0", + Configuration: apiConfig, + SourceConfiguration: apiConfig, + DesiredState: models.StateDeployed, + Origin: models.OriginGatewayAPI, + } + + require.NoError(t, db.SaveConfig(cfg)) + + retrieved, err := db.GetConfig(cfg.UUID) + require.NoError(t, err) + + spec := retrieved.Configuration.(api.RestAPI).Spec + require.NotNil(t, spec.UpstreamDefinitions, "upstreamDefinitions must survive roundtrip") + require.Len(t, *spec.UpstreamDefinitions, 2) + defs := *spec.UpstreamDefinitions + assert.Equal(t, "users-svc", defs[0].Name) + require.Len(t, defs[0].Upstreams, 1) + assert.Equal(t, "http://users-backend:9080", defs[0].Upstreams[0].Url) + assert.Equal(t, "users-sandbox-svc", defs[1].Name) + require.Len(t, defs[1].Upstreams, 1) + assert.Equal(t, "http://users-sandbox:9080", defs[1].Upstreams[0].Url) + require.Len(t, spec.Operations, 1) + + op := spec.Operations[0] + require.NotNil(t, op.Upstream, "per-op upstream must survive roundtrip") + require.NotNil(t, op.Upstream.Main) + assert.Equal(t, "users-svc", op.Upstream.Main.Ref) + require.NotNil(t, op.Upstream.Sandbox) + assert.Equal(t, "users-sandbox-svc", op.Upstream.Sandbox.Ref) +} diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go b/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go index 669d4e8c18..a36d655e40 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go @@ -411,6 +411,59 @@ func TestBuildDynamicMetadata_WithPath(t *testing.T) { assert.Equal(t, "/new/path", extProc.Fields["path"].GetStringValue()) } +// ============================================================================= +// Per-op upstream + dynamic-endpoint precedence (regression: no double base prefix) +// ============================================================================= + +// TestTranslateRequestHeaderActions_DynamicEndpointDoesNotBakeBasePath guards the +// per-op-upstream-ref behavior. When a dynamic-endpoint policy redirects a request to an +// upstreamDefinition that has a base path, the kernel must pass the original request path +// plus target_upstream_base_path so Lua prepends the base exactly once. +func TestTranslateRequestHeaderActions_DynamicEndpointDoesNotBakeBasePath(t *testing.T) { + kernel := NewKernel() + chainExecutor := executor.NewChainExecutor(nil, nil, nil) + server := NewExternalProcessorServer(kernel, chainExecutor, config.TracingConfig{}, "") + + chain := ®istry.PolicyChain{} + execCtx := newPolicyExecutionContext(server, "test-route", chain) + execCtx.sharedCtx = &policy.SharedContext{} + execCtx.requestBodyCtx = &policy.RequestContext{ + Path: "/per-op/v1.0/override", + SharedContext: execCtx.sharedCtx, + } + execCtx.apiContext = "/per-op/v1.0" + execCtx.upstreamBasePath = "/ref-svc" // the per-op route's default base path + execCtx.upstreamDefinitionPaths = map[string]string{ + "op-policy-svc": "/op-policy-svc", + } + + targetUpstream := "op-policy-svc" + result := &executor.RequestHeaderExecutionResult{ + Results: []executor.RequestHeaderPolicyResult{ + { + Action: policy.UpstreamRequestHeaderModifications{ + UpstreamName: &targetUpstream, + }, + }, + }, + } + + resp, err := TranslateRequestHeaderActions(result, chain, execCtx) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.DynamicMetadata) + + extProc := resp.DynamicMetadata.Fields[constants.ExtProcFilterName].GetStructValue() + require.NotNil(t, extProc) + + // The target upstream's base path is advertised so the Lua prepends it exactly once. + assert.Equal(t, "/op-policy-svc", extProc.Fields["target_upstream_base_path"].GetStringValue()) + // The ORIGINAL request path is handed to Lua via the single path metadata channel, + // not a pre-computed base-prefixed path. + assert.Equal(t, "/per-op/v1.0/override", extProc.Fields["path"].GetStringValue()) + assert.NotContains(t, extProc.Fields, "request_transformation.target_path") +} + // ============================================================================= // translateRequestActionsCore Tests // ============================================================================= diff --git a/gateway/it/features/api-level-url-stable.feature b/gateway/it/features/api-level-url-stable.feature new file mode 100644 index 0000000000..5311c6a7be --- /dev/null +++ b/gateway/it/features/api-level-url-stable.feature @@ -0,0 +1,484 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +@api-level-url-stable +Feature: API-Level Upstream URL-Stable Cluster Naming + As an API developer + I want API-level main and sandbox cluster names to stay stable across + upstream URL edits + So that routes, name-keyed stats, and cluster identity survive URL changes + and requests keep succeeding during updates + + Background: + Given the gateway services are running + + Scenario: API-level main upstream URL update (host and path change) routes to new backend (URL-stable cluster naming) + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-main-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Main-API + version: v1.0 + context: /api-level-url-stable-main/$version + vhosts: + main: api-level-url-stable-main.local + upstream: + main: + url: http://sample-backend:9080/version-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" + + When I clear all headers + And I set request host to "api-level-url-stable-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-a/endpoint" + + # Envoy admin: the API-level cluster must use the identity-derived name + # (main_) and there must be no URL-derived (cluster__) + # cluster. The URL-derived form is what the pre-change naming produced, so + # this assertion fails on the old naming scheme. The exact name set is + # captured so the post-update step can prove the NAME survived the update. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And I capture the Envoy cluster names prefixed "main_" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-main-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-main-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Main-API + version: v1.0 + context: /api-level-url-stable-main/$version + vhosts: + main: api-level-url-stable-main.local + upstream: + main: + # The host changes too (container alias of the same backend), proving + # the cluster survives a HOST edit, not only a path edit. The old + # URL-derived naming kept its name across path edits but renamed the + # cluster on any host or scheme change. + url: http://it-sample-backend:9080/version-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" + + When I clear all headers + And I set request host to "api-level-url-stable-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-b/endpoint" + + # After the HOST change the exact cluster-name set must be UNCHANGED: + # this proves the same main_ cluster survived the host edit (a + # rename to a different main_ would fail the unchanged step). The + # old naming would have minted a new cluster_http_it-sample-backend_9080 + # cluster here and dropped the previous one. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "main_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-main-api-v1.0" + Then the response should be successful + + Scenario: API-level sandbox upstream URL update (host and path change) routes to new backend + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-sandbox-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Sandbox-API + version: v1.0 + context: /api-level-url-stable-sandbox/$version + vhosts: + main: api-level-url-stable-sandbox-main.local + sandbox: api-level-url-stable-sandbox-sb.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/sandbox-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-url-stable-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/sandbox-a/endpoint" + + # Capture the sandbox cluster-name set so the post-update step can prove + # the sandbox_ name survived the URL update. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And I capture the Envoy cluster names prefixed "sandbox_" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-sandbox-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-sandbox-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Sandbox-API + version: v1.0 + context: /api-level-url-stable-sandbox/$version + vhosts: + main: api-level-url-stable-sandbox-main.local + sandbox: api-level-url-stable-sandbox-sb.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + # The sandbox host changes too (container alias of the same + # backend), so this update exercises a host edit on the sandbox + # cluster, not only a path edit. + url: http://it-sample-backend:9080/sandbox-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-url-stable-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/sandbox-b/endpoint" + + # Envoy admin: the sandbox cluster must use the identity-derived name + # (sandbox_); no URL-derived cluster may exist, and the exact name + # set must be unchanged across the host edit (identity proof). Fails on + # the old URL-derived naming scheme. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "sandbox_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-sandbox-api-v1.0" + Then the response should be successful + + Scenario: API-level upstream with cluster_header routing (default upstream cluster resolves correctly) + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-default-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Default-API + version: v1.0 + context: /api-level-url-stable-default/$version + vhosts: + main: api-level-url-stable-default.local + upstreamDefinitions: + - name: backend-default + basePath: /api-main + upstreams: + - url: http://sample-backend:9080 + upstream: + main: + ref: backend-default + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" to be ready with host "api-level-url-stable-default.local" + + When I clear all headers + And I set request host to "api-level-url-stable-default.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-default-api-v1.0" + Then the response should be successful + + Scenario: API-level main and sandbox on the same backend host get separate identity-named clusters + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-collision-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Collision-API + version: v1.0 + context: /api-level-url-stable-collision/$version + vhosts: + main: api-level-url-stable-collision-main.local + sandbox: api-level-url-stable-collision-sb.local + upstream: + main: + url: http://sample-backend:9080/collision-main + sandbox: + url: http://sample-backend:9080/collision-sandbox + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" to be ready with host "api-level-url-stable-collision-main.local" + + # Main and sandbox share the same backend host:port but must route to their + # own base paths. The old URL-derived naming keyed the cluster on host and + # scheme only, so main and sandbox collapsed into one shared cluster here; + # identity naming gives each its own. + When I clear all headers + And I set request host to "api-level-url-stable-collision-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/collision-main/endpoint" + + When I clear all headers + And I set request host to "api-level-url-stable-collision-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/collision-sandbox/endpoint" + + # Envoy admin: an identity-named main_ and a sandbox_ cluster + # must both exist (they do not collide), and no URL-derived cluster may + # exist. Under the old naming both upstreams shared one cluster__ + # cluster, so this assertion fails on the previous scheme. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-collision-api-v1.0" + Then the response should be successful + + Scenario: Two APIs sharing the same backend host each route under their own identity-named cluster + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-shared-a-v1.0 + spec: + displayName: API-Level-URL-Stable-Shared-A + version: v1.0 + context: /api-level-url-stable-shared-a/$version + vhosts: + main: api-level-url-stable-shared-a.local + upstream: + main: + url: http://sample-backend:9080/shared-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-a.local" + + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-shared-b-v1.0 + spec: + displayName: API-Level-URL-Stable-Shared-B + version: v1.0 + context: /api-level-url-stable-shared-b/$version + vhosts: + main: api-level-url-stable-shared-b.local + upstream: + main: + url: http://sample-backend:9080/shared-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-b.local" + + # Two distinct APIs point at the same backend host:port. The old URL-derived + # naming made them share one cluster__ cluster; identity naming + # keys each cluster on its apiID, so the two APIs route independently to their + # own base paths under identity-named clusters and no URL-derived cluster exists. + When I clear all headers + And I set request host to "api-level-url-stable-shared-a.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-a/endpoint" + + When I clear all headers + And I set request host to "api-level-url-stable-shared-b.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-b/endpoint" + + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + + # Delete API-B and confirm API-A still routes, proving the two APIs own + # independent clusters (deleting one does not disturb the other). + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-shared-b-v1.0" + Then the response should be successful + + When I clear all headers + And I set request host to "api-level-url-stable-shared-a.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-a/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-shared-a-v1.0" + Then the response should be successful + + Scenario: API-level main upstream scheme and port change keeps the same identity-named cluster + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-scheme-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Scheme-API + version: v1.0 + context: /api-level-url-stable-scheme/$version + vhosts: + main: api-level-url-stable-scheme.local + upstream: + main: + url: http://sample-backend:9080/version-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-scheme/v1.0/endpoint" to be ready with host "api-level-url-stable-scheme.local" + + # Capture the identity-derived cluster name while the upstream is plain http. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And I capture the Envoy cluster names prefixed "main_" + + # Change the upstream scheme (http -> https) AND port (9080 -> 9443) in one + # edit. The old URL-derived naming embedded scheme and port in the cluster + # name (cluster___), so this edit would have minted a new + # cluster_https_ cluster and dropped the previous one. Identity-based naming + # must keep the SAME main_ and never produce a cluster_https_. TLS + # routing itself is not asserted (there is no TLS echo backend); the cluster + # name is stable independent of upstream reachability. + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-scheme-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-scheme-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Scheme-API + version: v1.0 + context: /api-level-url-stable-scheme/$version + vhosts: + main: api-level-url-stable-scheme.local + upstream: + main: + url: https://sample-backend:9443/version-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + + # The main_ name set must be UNCHANGED after the scheme/port edit, and + # no URL-derived cluster_https_ may appear. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "main_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-scheme-api-v1.0" + Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-basic.feature b/gateway/it/features/per-op-upstream-basic.feature new file mode 100644 index 0000000000..c470f26d6f --- /dev/null +++ b/gateway/it/features/per-op-upstream-basic.feature @@ -0,0 +1,474 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +@per-op-upstream-basic +Feature: Per-Operation Upstream Basic Routing + As an API developer + I want per-operation upstream refs to override API-level upstreams + So that different operations can route to different backends + + Background: + Given the gateway services are running + + Scenario: API-level main fallback when operation has no per-op upstream + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-fm-api-v1.0 + spec: + displayName: Per-Op-Basic-FM-API + version: v1.0 + context: /per-op-basic-fm/$version + vhosts: + main: per-op-basic-fm-main.local + sandbox: per-op-basic-fm-sandbox.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-fm/v1.0/users" to be ready with host "per-op-basic-fm-main.local" + + When I clear all headers + And I set request host to "per-op-basic-fm-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-fm/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-fm-api-v1.0" + Then the response should be successful + + Scenario: API-level sandbox fallback when operation has no per-op upstream + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-fs-api-v1.0 + spec: + displayName: Per-Op-Basic-FS-API + version: v1.0 + context: /per-op-basic-fs/$version + vhosts: + main: per-op-basic-fs-main.local + sandbox: per-op-basic-fs-sandbox.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-fs/v1.0/users" to be ready with host "per-op-basic-fs-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-fs-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-fs/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-fs-api-v1.0" + Then the response should be successful + + Scenario: Per-operation main ref overrides API-level main + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-om-api-v1.0 + spec: + displayName: Per-Op-Basic-OM-API + version: v1.0 + context: /per-op-basic-om/$version + vhosts: + main: per-op-basic-om-main.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + - method: GET + path: /orders + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-om/v1.0/users" to be ready with host "per-op-basic-om-main.local" + + When I clear all headers + And I set request host to "per-op-basic-om-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-om/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-main/users" + + When I clear all headers + And I set request host to "per-op-basic-om-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-om/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-om-api-v1.0" + Then the response should be successful + + Scenario: Per-operation sandbox-only override routes sandbox traffic to the operation upstream + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-os-api-v1.0 + spec: + displayName: Per-Op-Basic-OS-API + version: v1.0 + context: /per-op-basic-os/$version + vhosts: + main: per-op-basic-os-main.local + sandbox: per-op-basic-os-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-os/v1.0/users" to be ready with host "per-op-basic-os-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-os-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-os/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-os-api-v1.0" + Then the response should be successful + + Scenario: Sandbox falls back when operation only has per-op main + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-sf-api-v1.0 + spec: + displayName: Per-Op-Basic-SF-API + version: v1.0 + context: /per-op-basic-sf/$version + vhosts: + main: per-op-basic-sf-main.local + sandbox: per-op-basic-sf-sandbox.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-sf/v1.0/users" to be ready with host "per-op-basic-sf-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-sf-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-sf/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-sf-api-v1.0" + Then the response should be successful + + Scenario: Main falls back when operation only has per-op sandbox + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-mf-api-v1.0 + spec: + displayName: Per-Op-Basic-MF-API + version: v1.0 + context: /per-op-basic-mf/$version + vhosts: + main: per-op-basic-mf-main.local + sandbox: per-op-basic-mf-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-mf/v1.0/users" to be ready with host "per-op-basic-mf-main.local" + + When I clear all headers + And I set request host to "per-op-basic-mf-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-mf/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-mf-api-v1.0" + Then the response should be successful + + Scenario: Operation with both per-op main and sandbox overrides + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-both-api-v1.0 + spec: + displayName: Per-Op-Basic-Both-API + version: v1.0 + context: /per-op-basic-both/$version + vhosts: + main: per-op-basic-both-main.local + sandbox: per-op-basic-both-sandbox.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-both/v1.0/users" to be ready with host "per-op-basic-both-main.local" + + When I clear all headers + And I set request host to "per-op-basic-both-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-both/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-main/users" + + When I clear all headers + And I set request host to "per-op-basic-both-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-both/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-both-api-v1.0" + Then the response should be successful + + Scenario: Per-operation upstream definition basePath update routes to new path (URL-stable cluster naming) + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-eds-api-v1.0 + spec: + displayName: Per-Op-Basic-EDS-API + version: v1.0 + context: /per-op-basic-eds/$version + vhosts: + main: per-op-basic-eds-main.local + upstreamDefinitions: + - name: op-versioned-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /version-a + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /endpoint + upstream: + main: + ref: op-versioned-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" to be ready with host "per-op-basic-eds-main.local" + + When I clear all headers + And I set request host to "per-op-basic-eds-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-a/endpoint" + + Given I authenticate using basic auth as "admin" + When I update the API "per-op-basic-eds-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-eds-api-v1.0 + spec: + displayName: Per-Op-Basic-EDS-API + version: v1.0 + context: /per-op-basic-eds/$version + vhosts: + main: per-op-basic-eds-main.local + upstreamDefinitions: + - name: op-versioned-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /version-b + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /endpoint + upstream: + main: + ref: op-versioned-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" to be ready with host "per-op-basic-eds-main.local" + + When I clear all headers + And I set request host to "per-op-basic-eds-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-b/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-eds-api-v1.0" + Then the response should be successful + + Scenario: Per-op sandbox inherits API-level main hostRewrite when no API-level sandbox + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-sbhr-api-v1.0 + spec: + displayName: Per-Op-Basic-SBHR-API + version: v1.0 + context: /per-op-basic-sbhr/$version + vhosts: + main: per-op-basic-sbhr-main.local + sandbox: per-op-basic-sbhr-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://echo-backend:80 + basePath: /anything + upstream: + main: + url: http://echo-backend:80/anything + hostRewrite: manual + operations: + - method: GET + path: /test + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-sbhr/v1.0/test" to be ready with host "per-op-basic-sbhr-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-sbhr-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-sbhr/v1.0/test" + Then the response status code should be 200 + And the JSON response field "headers.Host" should be "per-op-basic-sbhr-sandbox.local" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-sbhr-api-v1.0" + Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-ref.feature b/gateway/it/features/per-op-upstream-ref.feature new file mode 100644 index 0000000000..666d9f1aff --- /dev/null +++ b/gateway/it/features/per-op-upstream-ref.feature @@ -0,0 +1,307 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +@per-op-upstream-ref +Feature: Per-Operation Upstream Ref + As an API developer + I want per-operation upstream refs to resolve through upstreamDefinitions + So that different operations can route to different backends + + Background: + Given the gateway services are running + + Scenario: Per-operation main refs route to different backend services on different ports + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-ref-api-v1.0 + spec: + displayName: Per-Op-Ref-API + version: v1.0 + context: /per-op/$version + vhosts: + main: per-op-main.local + upstreamDefinitions: + - name: users-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /user-svc + - name: orders-svc + upstreams: + - url: http://echo-backend:80 + basePath: /anything + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: users-svc + - method: GET + path: /orders + upstream: + main: + ref: orders-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op/v1.0/users" to be ready with host "per-op-main.local" + And I wait for the endpoint "http://localhost:8080/per-op/v1.0/orders" to be ready with host "per-op-main.local" + + When I clear all headers + And I set request host to "per-op-main.local" + And I send a GET request to "http://localhost:8080/per-op/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/user-svc/users" + + When I clear all headers + And I set request host to "per-op-main.local" + And I send a GET request to "http://localhost:8080/per-op/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "url" should be "http://echo-backend/anything/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-ref-api-v1.0" + Then the response should be successful + + Scenario: Mixed operations - one with per-op ref, one falling back to API-level + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-mixed-api-v1.0 + spec: + displayName: Per-Op-Mixed-API + version: v1.0 + context: /per-op-mixed/$version + vhosts: + main: per-op-mixed-main.local + upstreamDefinitions: + - name: users-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /user-svc + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + main: + ref: users-svc + - method: GET + path: /orders + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-mixed/v1.0/users" to be ready with host "per-op-mixed-main.local" + + When I clear all headers + And I set request host to "per-op-mixed-main.local" + And I send a GET request to "http://localhost:8080/per-op-mixed/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/user-svc/users" + + When I clear all headers + And I set request host to "per-op-mixed-main.local" + And I send a GET request to "http://localhost:8080/per-op-mixed/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-mixed-api-v1.0" + Then the response should be successful + + Scenario: Operation-level dynamic-endpoint policy overrides the per-op ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-prec-op-api-v1.0 + spec: + displayName: Per-Op-Prec-Op-API + version: v1.0 + context: /per-op-prec-op/$version + vhosts: + main: per-op-prec-op-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + - name: op-policy-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-policy-svc + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /override + upstream: + main: + ref: ref-svc + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: op-policy-svc + - method: GET + path: /fallback + upstream: + main: + ref: ref-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-prec-op/v1.0/fallback" to be ready with host "per-op-prec-op-main.local" + + # Operation-level dynamic-endpoint policy wins over the per-op ref. + When I clear all headers + And I set request host to "per-op-prec-op-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-op/v1.0/override" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-policy-svc/override" + + # No policy on this op: the per-op ref is the default. + When I clear all headers + And I set request host to "per-op-prec-op-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-op/v1.0/fallback" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/ref-svc/fallback" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-prec-op-api-v1.0" + Then the response should be successful + + Scenario: API-level dynamic-endpoint policy overrides the per-op ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-prec-api-api-v1.0 + spec: + displayName: Per-Op-Prec-Api-API + version: v1.0 + context: /per-op-prec-api/$version + vhosts: + main: per-op-prec-api-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + - name: global-policy-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /global-policy-svc + upstream: + main: + url: http://sample-backend:9080/api-main + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: global-policy-svc + operations: + - method: GET + path: /items + upstream: + main: + ref: ref-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-prec-api/v1.0/items" to be ready with host "per-op-prec-api-main.local" + + # API-level dynamic-endpoint policy wins over the per-op ref (dynamic beats static upstream). + When I clear all headers + And I set request host to "per-op-prec-api-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-api/v1.0/items" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/global-policy-svc/items" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-prec-api-api-v1.0" + Then the response should be successful + + Scenario: Request-rewrite policy composes with a per-op ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-ref-rewrite-api-v1.0 + spec: + displayName: Per-Op-Ref-Rewrite-API + version: v1.0 + context: /per-op-ref-rewrite/$version + vhosts: + main: per-op-ref-rewrite-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /whoami + upstream: + main: + ref: ref-svc + policies: + - name: request-rewrite + version: v1 + params: + pathRewrite: + type: ReplaceFullPath + replaceFullPath: /rewritten + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-ref-rewrite/v1.0/whoami" to be ready with host "per-op-ref-rewrite-main.local" + + When I clear all headers + And I set request host to "per-op-ref-rewrite-main.local" + And I send a GET request to "http://localhost:8080/per-op-ref-rewrite/v1.0/whoami" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/ref-svc/rewritten" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-ref-rewrite-api-v1.0" + Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-validation.feature b/gateway/it/features/per-op-upstream-validation.feature new file mode 100644 index 0000000000..7145711722 --- /dev/null +++ b/gateway/it/features/per-op-upstream-validation.feature @@ -0,0 +1,202 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +@per-op-upstream-validation +Feature: Per-Operation Upstream Validation + As an API developer + I want malformed per-operation upstream configurations to be rejected + So that invalid APIs cannot be deployed + + Background: + Given the gateway services are running + + Scenario: Empty per-op upstream wrapper is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-empty-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-API + version: v1.0 + context: /per-op-val-empty/$version + vhosts: + main: per-op-val-empty-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: {} + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "At least one of 'main' or 'sandbox' must be set" + + Scenario: Per-op ref to non-existent upstream definition is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-missing-ref-api-v1.0 + spec: + displayName: Per-Op-Val-Missing-Ref-API + version: v1.0 + context: /per-op-val-missing-ref/$version + vhosts: + main: per-op-val-missing-ref-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: does-not-exist + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Referenced upstream definition 'does-not-exist' not found" + + Scenario: Empty per-op leaf is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-empty-leaf-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-Leaf-API + version: v1.0 + context: /per-op-val-empty-leaf/$version + vhosts: + main: per-op-val-empty-leaf-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: {} + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Upstream ref is required" + + Scenario: Empty per-op sandbox leaf with no API-level sandbox is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-empty-sandbox-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-Sandbox-API + version: v1.0 + context: /per-op-val-empty-sandbox/$version + vhosts: + main: per-op-val-empty-sandbox-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + sandbox: {} + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Upstream ref is required" + + Scenario: Per-op ref with invalid characters is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-bad-ref-api-v1.0 + spec: + displayName: Per-Op-Val-Bad-Ref-API + version: v1.0 + context: /per-op-val-bad-ref/$version + vhosts: + main: per-op-val-bad-ref-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: "bad/ref!" + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Upstream ref must match pattern" + + Scenario: Non-positive upstreamDefinition timeout is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-neg-timeout-api-v1.0 + spec: + displayName: Per-Op-Val-Neg-Timeout-API + version: v1.0 + context: /per-op-val-neg-timeout/$version + vhosts: + main: per-op-val-neg-timeout-main.local + upstreamDefinitions: + - name: slow-svc + timeout: + connect: 0s + upstreams: + - url: http://sample-backend:9080 + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: slow-svc + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Connect timeout must be a positive duration" diff --git a/gateway/it/steps_envoy_admin.go b/gateway/it/steps_envoy_admin.go new file mode 100644 index 0000000000..42bbb6fc6b --- /dev/null +++ b/gateway/it/steps_envoy_admin.go @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package it + +import ( + "context" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/cucumber/godog" +) + +// envoyAdminURL is the Envoy admin endpoint exposed by the IT compose stack. +const envoyAdminURL = "http://localhost:9901" + +// envoyAdminClient bounds admin queries so a stalled admin endpoint fails the +// step instead of hanging until the suite timeout. +var envoyAdminClient = &http.Client{Timeout: 10 * time.Second} + +// rememberedClusterSets holds cluster-name sets captured during a scenario, +// keyed by name prefix. Cleared before each scenario. Safe under godog's +// default sequential execution; it would need per-scenario state if scenario +// parallelism is ever enabled. +var rememberedClusterSets = map[string][]string{} + +// fetchEnvoyClusterNames returns the sorted, de-duplicated set of cluster +// names with the given prefix, parsed from the Envoy admin /clusters output +// (each line has the form "::::"). +func fetchEnvoyClusterNames(prefix string) ([]string, error) { + resp, err := envoyAdminClient.Get(envoyAdminURL + "/clusters") + if err != nil { + return nil, fmt.Errorf("failed to query Envoy admin /clusters: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Envoy admin /clusters returned status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read Envoy admin /clusters response: %w", err) + } + seen := map[string]bool{} + for _, line := range strings.Split(string(body), "\n") { + name, _, ok := strings.Cut(line, "::") + if !ok { + continue + } + if strings.HasPrefix(name, prefix) { + seen[name] = true + } + } + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + return names, nil +} + +// RegisterEnvoyAdminSteps registers steps that assert Envoy cluster identity +// via the admin endpoint. Capturing the exact cluster-name set before an API +// update and asserting it is unchanged afterwards proves the cluster NAME +// survived the update; substring checks on /clusters alone cannot prove that +// (an implementation renaming one hashed cluster to another would still pass +// a "contains prefix" check). +func RegisterEnvoyAdminSteps(ctx *godog.ScenarioContext) { + ctx.Before(func(c context.Context, sc *godog.Scenario) (context.Context, error) { + rememberedClusterSets = map[string][]string{} + return c, nil + }) + + ctx.Step(`^I capture the Envoy cluster names prefixed "([^"]*)"$`, func(prefix string) error { + names, err := fetchEnvoyClusterNames(prefix) + if err != nil { + return err + } + if len(names) == 0 { + return fmt.Errorf("no Envoy clusters with prefix %q found to capture", prefix) + } + rememberedClusterSets[prefix] = names + return nil + }) + + ctx.Step(`^the Envoy cluster names prefixed "([^"]*)" should be unchanged$`, func(prefix string) error { + captured, ok := rememberedClusterSets[prefix] + if !ok { + return fmt.Errorf("no captured cluster set for prefix %q; use the capture step first", prefix) + } + current, err := fetchEnvoyClusterNames(prefix) + if err != nil { + return err + } + if strings.Join(captured, ",") != strings.Join(current, ",") { + return fmt.Errorf("Envoy cluster set with prefix %q changed across the update: before=%v after=%v (cluster identity must be stable)", prefix, captured, current) + } + return nil + }) +} diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index b1f2023a1c..1cb601858e 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -143,6 +143,10 @@ func getFeaturePaths() []string { "features/route-path-matching.feature", "features/secrets.feature", "features/template-functions.feature", + "features/per-op-upstream-basic.feature", + "features/per-op-upstream-ref.feature", + "features/per-op-upstream-validation.feature", + "features/api-level-url-stable.feature", // These tests require different gateway configurations and are not included in the default suite run. // "features/vhost-routing-single.feature", // cd it && make test-vhosts-single // "features/vhost-routing-multi.feature", // cd it && make test-vhosts-multi @@ -345,6 +349,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { RegisterSubscriptionSteps(ctx, testState, httpSteps) RegisterSecretSteps(ctx, testState, httpSteps) RegisterTemplateSteps(ctx, testState, httpSteps) + RegisterEnvoyAdminSteps(ctx) } // Register common HTTP and assertion steps diff --git a/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md b/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md index bb7ad083af..90fdb1f74d 100644 --- a/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md +++ b/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md @@ -661,8 +661,8 @@ For each API Configuration, create a RouteConfiguration: - **Routes**: One route per operation mapping `{method, path}` to cluster #### Cluster Creation -For each unique upstream URL, create a Cluster: -- **Name**: `cluster_{sanitized_upstream_url}` (e.g., `cluster_api_weather_com`) +For each API-level upstream (main/sandbox), create a Cluster: +- **Name**: `{env}_{hash}` where `hash` is the first 12 bytes of `sha256(apiID)` in hex (e.g., `main_f9811b73ac5d1a8db842634f`); main and sandbox share the hash, distinguished by the env prefix. The name is derived from the API's identity, not the URL, so a URL edit never renames the cluster: host/port/scheme changes are applied as an update to the same named cluster (warmed and swapped), path-only changes touch just the route rewrite, and routes and name-keyed stats stay continuous either way. - **Type**: `STRICT_DNS` or `LOGICAL_DNS` - **Load Assignment**: Endpoint with upstream host and port @@ -691,9 +691,9 @@ data: - **Listener**: `listener_http_8080` listening on `0.0.0.0:8080` - **Route**: `route_weather_api_v1_0` - Match: `GET /weather/{country_code}/{city}` - - Action: Forward to `cluster_api_weather_com` + - Action: Forward to `main_` (the API's main upstream cluster) - Prefix Rewrite: Prepend `/api/v2` → final path: `/api/v2/{country_code}/{city}` -- **Cluster**: `cluster_api_weather_com` +- **Cluster**: `main_` (identity-derived name, stable across URL edits) - Host: `api.weather.com:443` - TLS: Enabled (HTTPS upstream)