From 7cff05d2f97457f872b5806c89c04321872275e8 Mon Sep 17 00:00:00 2001 From: Renuka Fernando Date: Thu, 21 May 2026 11:12:08 +0530 Subject: [PATCH 1/2] fix(policy-engine): resolve upstream base path for dynamic routing Two bugs prevented the dynamic-endpoint policy from applying a target upstream definition's base path when routing via UpstreamName: - The controller exposed upstream base paths keyed by an internal cluster key, but the policy engine looked them up by the upstream definition name (the policy's targetUpstream value), so the lookup always missed and the base path defaulted to "/". Key the map by definition name in the controller so both sides agree, and add a Name field to UpstreamCluster to carry it. - Even once resolved, the routed path was built by naive concatenation that left the API context in place (e.g. /foo/ctx/v1/items). Use computeUpstreamPath to strip the context before prepending the upstream base path. Add gateway integration test features/dynamic-endpoint.feature covering named-upstream routing, per-operation base paths, and the required targetUpstream parameter, and register it in the default suite. --- .../pkg/models/runtime_deploy_config.go | 1 + .../pkg/policyxds/snapshot.go | 9 +- .../pkg/transform/restapi.go | 1 + .../policy-engine/internal/kernel/extproc.go | 2 +- .../internal/kernel/translator.go | 4 +- gateway/it/features/dynamic-endpoint.feature | 171 ++++++++++++++++++ gateway/it/suite_test.go | 1 + 7 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 gateway/it/features/dynamic-endpoint.feature diff --git a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go index a1b0c66c2..8dec6fa8c 100644 --- a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go +++ b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go @@ -88,6 +88,7 @@ type Policy struct { // UpstreamCluster represents an Envoy cluster with its endpoints. type UpstreamCluster struct { + Name string // upstream definition name; "" for the main/sandbox slot clusters BasePath string Endpoints []Endpoint TLS *UpstreamTLS diff --git a/gateway/gateway-controller/pkg/policyxds/snapshot.go b/gateway/gateway-controller/pkg/policyxds/snapshot.go index b4d71a236..3aa5fc875 100644 --- a/gateway/gateway-controller/pkg/policyxds/snapshot.go +++ b/gateway/gateway-controller/pkg/policyxds/snapshot.go @@ -232,10 +232,13 @@ func (t *Translator) TranslateRuntimeConfigs(rdcs []*models.RuntimeDeployConfig) upstreamBasePath = uc.BasePath } - // Build upstream definition paths + // Build upstream definition paths, keyed by definition name so the + // policy engine can resolve them from a policy's targetUpstream value. upstreamDefPaths := make(map[string]string) - for clusterKey, uc := range rdc.UpstreamClusters { - upstreamDefPaths[clusterKey] = uc.BasePath + for _, uc := range rdc.UpstreamClusters { + if uc.Name != "" { + upstreamDefPaths[uc.Name] = uc.BasePath + } } resource, err := t.createRouteConfigResource(routeKey, rdc, upstreamBasePath, upstreamDefPaths) diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index ac1826d0e..e5813ebd1 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -199,6 +199,7 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim basePath = "/" } rdc.UpstreamClusters[defClusterKey] = &models.UpstreamCluster{ + Name: def.Name, BasePath: basePath, Endpoints: []models.Endpoint{{ Host: parsedURL.Hostname(), diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go index 23e6ed6f5..5d75ea635 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go @@ -455,7 +455,7 @@ type RouteMetadata struct { ProjectID string DefaultUpstreamCluster string // Default cluster for dynamic cluster routing UpstreamBasePath string // Base path for the upstream (e.g., /anything) - UpstreamDefinitionPaths map[string]string // Maps upstream definition names to their URL paths + UpstreamDefinitionPaths map[string]string // Maps upstream definition names to their URL base paths } // generateRequestID generates a unique request identifier diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go b/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go index 0958e7188..96bcf2b66 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go @@ -462,7 +462,7 @@ func TranslateRequestHeaderActions(result *executor.RequestHeaderExecutionResult if execCtx.upstreamDefinitionPaths != nil { if targetUpstreamPath, ok := execCtx.upstreamDefinitionPaths[*targetUpstreamName]; ok { if mutations.Path == nil { - computedPath := strings.TrimSuffix(targetUpstreamPath, "/") + execCtx.requestBodyCtx.Path + computedPath := computeUpstreamPath(execCtx.requestBodyCtx.Path, execCtx.apiContext, targetUpstreamPath) mutations.Path = &computedPath dynamicMetadata[extProcNS]["request_transformation.target_path"] = computedPath execCtx.dynamicMetadata[extProcNS]["request_transformation.target_path"] = computedPath @@ -686,7 +686,7 @@ func TranslateRequestHeaderActionsWithBodyMerge( if execCtx.upstreamDefinitionPaths != nil { if targetUpstreamPath, ok := execCtx.upstreamDefinitionPaths[*targetUpstreamName]; ok { if mutations.Path == nil { - computedPath := strings.TrimSuffix(targetUpstreamPath, "/") + execCtx.requestBodyCtx.Path + computedPath := computeUpstreamPath(execCtx.requestBodyCtx.Path, execCtx.apiContext, targetUpstreamPath) mutations.Path = &computedPath dynamicMetadata[extProcNS]["request_transformation.target_path"] = computedPath execCtx.dynamicMetadata[extProcNS]["request_transformation.target_path"] = computedPath diff --git a/gateway/it/features/dynamic-endpoint.feature b/gateway/it/features/dynamic-endpoint.feature new file mode 100644 index 000000000..3f0abba14 --- /dev/null +++ b/gateway/it/features/dynamic-endpoint.feature @@ -0,0 +1,171 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +@dynamic-endpoint +Feature: Dynamic Endpoint policy + As an API developer + I want the dynamic-endpoint policy to route an operation to a named upstream definition + So that specific operations can target alternate upstreams without changing the primary API structure + + Background: + Given the gateway services are running + + # The policy sets the SDK UpstreamName field, diverting the request from the default + # upstream.main to the named upstream definition. Operations without the policy keep + # using upstream.main. + Scenario: Operation routed to a named upstream definition while others use the default upstream + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: dynamic-endpoint-api-v1.0 + spec: + displayName: Dynamic-Endpoint-API + version: v1.0 + context: /dynamic-endpoint/$version + upstreamDefinitions: + - name: alt-upstream + upstreams: + - url: http://sample-backend:9080/alternate + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /whoami + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: alt-upstream + - method: GET + path: /ping + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/dynamic-endpoint/v1.0/ping" to be ready + + # Operation with the policy: diverted to alt-upstream. The backend echoes the + # path it received — the alt-upstream base path /alternate confirms the routing. + When I clear all headers + And I send a GET request to "http://localhost:8080/dynamic-endpoint/v1.0/whoami" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/alternate/whoami" + + # Operation without the policy: served by the default upstream.main (base path /). + When I clear all headers + And I send a GET request to "http://localhost:8080/dynamic-endpoint/v1.0/ping" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/ping" + + Given I authenticate using basic auth as "admin" + When I delete the API "dynamic-endpoint-api-v1.0" + Then the response should be successful + + # Each upstream definition carries a distinct base path. The policy must route each + # operation to its targeted upstream AND the upstream's base path must be prepended + # to the forwarded request path. + Scenario: Different operations route to upstreams with different base paths + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: dynamic-endpoint-routes-v1.0 + spec: + displayName: Dynamic-Endpoint-Routes-API + version: v1.0 + context: /dynamic-endpoint-routes/$version + upstreamDefinitions: + - name: foo-upstream + upstreams: + - url: http://sample-backend:9080/foo + - name: bar-upstream + upstreams: + - url: http://sample-backend:9080/bar + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /items + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: foo-upstream + - method: GET + path: /records + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: bar-upstream + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/dynamic-endpoint-routes/v1.0/items" to be ready + + # Routed to foo-upstream: base path /foo prepended. + When I clear all headers + And I send a GET request to "http://localhost:8080/dynamic-endpoint-routes/v1.0/items" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/foo/items" + + # Routed to bar-upstream: base path /bar prepended. + When I clear all headers + And I send a GET request to "http://localhost:8080/dynamic-endpoint-routes/v1.0/records" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/bar/records" + + Given I authenticate using basic auth as "admin" + When I delete the API "dynamic-endpoint-routes-v1.0" + Then the response should be successful + + # targetUpstream is a required parameter in the policy definition. + Scenario: Deploy fails when targetUpstream is omitted + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: RestApi + metadata: + name: dynamic-endpoint-missing-param-v1.0 + spec: + displayName: Dynamic-Endpoint-Missing-Param-API + version: v1.0 + context: /dynamic-endpoint-missing/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /whoami + policies: + - name: dynamic-endpoint + version: v1 + params: {} + """ + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "targetUpstream" diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index 2c0c98080..7eaaf1394 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -134,6 +134,7 @@ func getFeaturePaths() []string { "features/analytics-basic.feature", "features/token-based-ratelimit.feature", "features/sandbox-routing.feature", + "features/dynamic-endpoint.feature", "features/subscription-validation.feature", "features/subscription-analytics.feature", "features/llm-cost-based-ratelimit.feature", From 29a0bc273b209fbbbefc0bcc2e5e93e0829e522e Mon Sep 17 00:00:00 2001 From: Renuka Fernando Date: Fri, 22 May 2026 09:37:32 +0530 Subject: [PATCH 2/2] test(gateway): cover empty upstream base path for dynamic-endpoint Add a root-upstream definition with no URL path to the per-operation routing scenario, exercising the basePath="/" default in the controller and the empty-base-path branch of computeUpstreamPath in the policy engine, which strips the API context but prepends nothing. --- gateway/it/features/dynamic-endpoint.feature | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/gateway/it/features/dynamic-endpoint.feature b/gateway/it/features/dynamic-endpoint.feature index 3f0abba14..a97e54113 100644 --- a/gateway/it/features/dynamic-endpoint.feature +++ b/gateway/it/features/dynamic-endpoint.feature @@ -101,6 +101,9 @@ Feature: Dynamic Endpoint policy - name: bar-upstream upstreams: - url: http://sample-backend:9080/bar + - name: root-upstream + upstreams: + - url: http://sample-backend:9080 upstream: main: url: http://sample-backend:9080 @@ -119,6 +122,13 @@ Feature: Dynamic Endpoint policy version: v1 params: targetUpstream: bar-upstream + - method: GET + path: /extras + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: root-upstream """ Then the response should be successful And I wait for the endpoint "http://localhost:8080/dynamic-endpoint-routes/v1.0/items" to be ready @@ -137,6 +147,13 @@ Feature: Dynamic Endpoint policy And the response should be valid JSON And the JSON response field "path" should be "/bar/records" + # Routed to root-upstream: empty base path, so only the operation path reaches the backend. + When I clear all headers + And I send a GET request to "http://localhost:8080/dynamic-endpoint-routes/v1.0/extras" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/extras" + Given I authenticate using basic auth as "admin" When I delete the API "dynamic-endpoint-routes-v1.0" Then the response should be successful