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..a97e54113 --- /dev/null +++ b/gateway/it/features/dynamic-endpoint.feature @@ -0,0 +1,188 @@ +# -------------------------------------------------------------------- +# 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 + - name: root-upstream + upstreams: + - url: http://sample-backend:9080 + 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 + - 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 + + # 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" + + # 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 + + # 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",