Skip to content

Commit 943c653

Browse files
Merge pull request #2000 from renuka-fernando/dynamic-ep
fix(policy-engine): resolve upstream base path for dynamic routing
2 parents f7a2827 + 29a0bc2 commit 943c653

7 files changed

Lines changed: 200 additions & 6 deletions

File tree

gateway/gateway-controller/pkg/models/runtime_deploy_config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ type Policy struct {
8888

8989
// UpstreamCluster represents an Envoy cluster with its endpoints.
9090
type UpstreamCluster struct {
91+
Name string // upstream definition name; "" for the main/sandbox slot clusters
9192
BasePath string
9293
Endpoints []Endpoint
9394
TLS *UpstreamTLS

gateway/gateway-controller/pkg/policyxds/snapshot.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,13 @@ func (t *Translator) TranslateRuntimeConfigs(rdcs []*models.RuntimeDeployConfig)
232232
upstreamBasePath = uc.BasePath
233233
}
234234

235-
// Build upstream definition paths
235+
// Build upstream definition paths, keyed by definition name so the
236+
// policy engine can resolve them from a policy's targetUpstream value.
236237
upstreamDefPaths := make(map[string]string)
237-
for clusterKey, uc := range rdc.UpstreamClusters {
238-
upstreamDefPaths[clusterKey] = uc.BasePath
238+
for _, uc := range rdc.UpstreamClusters {
239+
if uc.Name != "" {
240+
upstreamDefPaths[uc.Name] = uc.BasePath
241+
}
239242
}
240243

241244
resource, err := t.createRouteConfigResource(routeKey, rdc, upstreamBasePath, upstreamDefPaths)

gateway/gateway-controller/pkg/transform/restapi.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim
199199
basePath = "/"
200200
}
201201
rdc.UpstreamClusters[defClusterKey] = &models.UpstreamCluster{
202+
Name: def.Name,
202203
BasePath: basePath,
203204
Endpoints: []models.Endpoint{{
204205
Host: parsedURL.Hostname(),

gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ type RouteMetadata struct {
455455
ProjectID string
456456
DefaultUpstreamCluster string // Default cluster for dynamic cluster routing
457457
UpstreamBasePath string // Base path for the upstream (e.g., /anything)
458-
UpstreamDefinitionPaths map[string]string // Maps upstream definition names to their URL paths
458+
UpstreamDefinitionPaths map[string]string // Maps upstream definition names to their URL base paths
459459
}
460460

461461
// generateRequestID generates a unique request identifier

gateway/gateway-runtime/policy-engine/internal/kernel/translator.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ func TranslateRequestHeaderActions(result *executor.RequestHeaderExecutionResult
462462
if execCtx.upstreamDefinitionPaths != nil {
463463
if targetUpstreamPath, ok := execCtx.upstreamDefinitionPaths[*targetUpstreamName]; ok {
464464
if mutations.Path == nil {
465-
computedPath := strings.TrimSuffix(targetUpstreamPath, "/") + execCtx.requestBodyCtx.Path
465+
computedPath := computeUpstreamPath(execCtx.requestBodyCtx.Path, execCtx.apiContext, targetUpstreamPath)
466466
mutations.Path = &computedPath
467467
dynamicMetadata[extProcNS]["request_transformation.target_path"] = computedPath
468468
execCtx.dynamicMetadata[extProcNS]["request_transformation.target_path"] = computedPath
@@ -686,7 +686,7 @@ func TranslateRequestHeaderActionsWithBodyMerge(
686686
if execCtx.upstreamDefinitionPaths != nil {
687687
if targetUpstreamPath, ok := execCtx.upstreamDefinitionPaths[*targetUpstreamName]; ok {
688688
if mutations.Path == nil {
689-
computedPath := strings.TrimSuffix(targetUpstreamPath, "/") + execCtx.requestBodyCtx.Path
689+
computedPath := computeUpstreamPath(execCtx.requestBodyCtx.Path, execCtx.apiContext, targetUpstreamPath)
690690
mutations.Path = &computedPath
691691
dynamicMetadata[extProcNS]["request_transformation.target_path"] = computedPath
692692
execCtx.dynamicMetadata[extProcNS]["request_transformation.target_path"] = computedPath
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# --------------------------------------------------------------------
2+
# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
3+
#
4+
# WSO2 LLC. licenses this file to you under the Apache License,
5+
# Version 2.0 (the "License"); you may not use this file except
6+
# in compliance with the License. You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing,
11+
# software distributed under the License is distributed on an
12+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13+
# KIND, either express or implied. See the License for the
14+
# specific language governing permissions and limitations
15+
# under the License.
16+
# --------------------------------------------------------------------
17+
18+
@dynamic-endpoint
19+
Feature: Dynamic Endpoint policy
20+
As an API developer
21+
I want the dynamic-endpoint policy to route an operation to a named upstream definition
22+
So that specific operations can target alternate upstreams without changing the primary API structure
23+
24+
Background:
25+
Given the gateway services are running
26+
27+
# The policy sets the SDK UpstreamName field, diverting the request from the default
28+
# upstream.main to the named upstream definition. Operations without the policy keep
29+
# using upstream.main.
30+
Scenario: Operation routed to a named upstream definition while others use the default upstream
31+
Given I authenticate using basic auth as "admin"
32+
When I deploy this API configuration:
33+
"""
34+
apiVersion: gateway.api-platform.wso2.com/v1alpha1
35+
kind: RestApi
36+
metadata:
37+
name: dynamic-endpoint-api-v1.0
38+
spec:
39+
displayName: Dynamic-Endpoint-API
40+
version: v1.0
41+
context: /dynamic-endpoint/$version
42+
upstreamDefinitions:
43+
- name: alt-upstream
44+
upstreams:
45+
- url: http://sample-backend:9080/alternate
46+
upstream:
47+
main:
48+
url: http://sample-backend:9080
49+
operations:
50+
- method: GET
51+
path: /whoami
52+
policies:
53+
- name: dynamic-endpoint
54+
version: v1
55+
params:
56+
targetUpstream: alt-upstream
57+
- method: GET
58+
path: /ping
59+
"""
60+
Then the response should be successful
61+
And I wait for the endpoint "http://localhost:8080/dynamic-endpoint/v1.0/ping" to be ready
62+
63+
# Operation with the policy: diverted to alt-upstream. The backend echoes the
64+
# path it received — the alt-upstream base path /alternate confirms the routing.
65+
When I clear all headers
66+
And I send a GET request to "http://localhost:8080/dynamic-endpoint/v1.0/whoami"
67+
Then the response should be successful
68+
And the response should be valid JSON
69+
And the JSON response field "path" should be "/alternate/whoami"
70+
71+
# Operation without the policy: served by the default upstream.main (base path /).
72+
When I clear all headers
73+
And I send a GET request to "http://localhost:8080/dynamic-endpoint/v1.0/ping"
74+
Then the response should be successful
75+
And the response should be valid JSON
76+
And the JSON response field "path" should be "/ping"
77+
78+
Given I authenticate using basic auth as "admin"
79+
When I delete the API "dynamic-endpoint-api-v1.0"
80+
Then the response should be successful
81+
82+
# Each upstream definition carries a distinct base path. The policy must route each
83+
# operation to its targeted upstream AND the upstream's base path must be prepended
84+
# to the forwarded request path.
85+
Scenario: Different operations route to upstreams with different base paths
86+
Given I authenticate using basic auth as "admin"
87+
When I deploy this API configuration:
88+
"""
89+
apiVersion: gateway.api-platform.wso2.com/v1alpha1
90+
kind: RestApi
91+
metadata:
92+
name: dynamic-endpoint-routes-v1.0
93+
spec:
94+
displayName: Dynamic-Endpoint-Routes-API
95+
version: v1.0
96+
context: /dynamic-endpoint-routes/$version
97+
upstreamDefinitions:
98+
- name: foo-upstream
99+
upstreams:
100+
- url: http://sample-backend:9080/foo
101+
- name: bar-upstream
102+
upstreams:
103+
- url: http://sample-backend:9080/bar
104+
- name: root-upstream
105+
upstreams:
106+
- url: http://sample-backend:9080
107+
upstream:
108+
main:
109+
url: http://sample-backend:9080
110+
operations:
111+
- method: GET
112+
path: /items
113+
policies:
114+
- name: dynamic-endpoint
115+
version: v1
116+
params:
117+
targetUpstream: foo-upstream
118+
- method: GET
119+
path: /records
120+
policies:
121+
- name: dynamic-endpoint
122+
version: v1
123+
params:
124+
targetUpstream: bar-upstream
125+
- method: GET
126+
path: /extras
127+
policies:
128+
- name: dynamic-endpoint
129+
version: v1
130+
params:
131+
targetUpstream: root-upstream
132+
"""
133+
Then the response should be successful
134+
And I wait for the endpoint "http://localhost:8080/dynamic-endpoint-routes/v1.0/items" to be ready
135+
136+
# Routed to foo-upstream: base path /foo prepended.
137+
When I clear all headers
138+
And I send a GET request to "http://localhost:8080/dynamic-endpoint-routes/v1.0/items"
139+
Then the response should be successful
140+
And the response should be valid JSON
141+
And the JSON response field "path" should be "/foo/items"
142+
143+
# Routed to bar-upstream: base path /bar prepended.
144+
When I clear all headers
145+
And I send a GET request to "http://localhost:8080/dynamic-endpoint-routes/v1.0/records"
146+
Then the response should be successful
147+
And the response should be valid JSON
148+
And the JSON response field "path" should be "/bar/records"
149+
150+
# Routed to root-upstream: empty base path, so only the operation path reaches the backend.
151+
When I clear all headers
152+
And I send a GET request to "http://localhost:8080/dynamic-endpoint-routes/v1.0/extras"
153+
Then the response should be successful
154+
And the response should be valid JSON
155+
And the JSON response field "path" should be "/extras"
156+
157+
Given I authenticate using basic auth as "admin"
158+
When I delete the API "dynamic-endpoint-routes-v1.0"
159+
Then the response should be successful
160+
161+
# targetUpstream is a required parameter in the policy definition.
162+
Scenario: Deploy fails when targetUpstream is omitted
163+
Given I authenticate using basic auth as "admin"
164+
When I deploy this API configuration:
165+
"""
166+
apiVersion: gateway.api-platform.wso2.com/v1alpha1
167+
kind: RestApi
168+
metadata:
169+
name: dynamic-endpoint-missing-param-v1.0
170+
spec:
171+
displayName: Dynamic-Endpoint-Missing-Param-API
172+
version: v1.0
173+
context: /dynamic-endpoint-missing/$version
174+
upstream:
175+
main:
176+
url: http://sample-backend:9080
177+
operations:
178+
- method: GET
179+
path: /whoami
180+
policies:
181+
- name: dynamic-endpoint
182+
version: v1
183+
params: {}
184+
"""
185+
Then the response should be a client error
186+
And the response should be valid JSON
187+
And the JSON response field "status" should be "error"
188+
And the response body should contain "targetUpstream"

gateway/it/suite_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ func getFeaturePaths() []string {
134134
"features/analytics-basic.feature",
135135
"features/token-based-ratelimit.feature",
136136
"features/sandbox-routing.feature",
137+
"features/dynamic-endpoint.feature",
137138
"features/subscription-validation.feature",
138139
"features/subscription-analytics.feature",
139140
"features/llm-cost-based-ratelimit.feature",

0 commit comments

Comments
 (0)