Skip to content
Open
25 changes: 25 additions & 0 deletions gateway/gateway-controller/pkg/config/api_validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,31 @@ func TestAPIValidator_ValidateAllHTTPMethods(t *testing.T) {
}
}

func TestAPIValidator_AcceptsNonUppercaseMethods(t *testing.T) {
v := NewAPIValidator()

// Lower- and mixed-case methods are accepted: the xDS translator normalizes
// the method to uppercase for Envoy's :method matcher, so validation must not
// reject them.
methods := []api.OperationMethod{"get", "Post", "dElEtE", "options"}

for _, method := range methods {
t.Run(string(method), func(t *testing.T) {
config := createValidRestAPIConfig()
config.Spec.Operations = []api.Operation{
{Method: method, Path: "/test"},
}

errors := v.Validate(config)
for _, e := range errors {
if strings.Contains(e.Field, "method") {
t.Errorf("unexpected method error for %q: %v", method, e)
}
}
})
}
}

func TestAPIValidator_ValidateWebSubAPI(t *testing.T) {
v := NewAPIValidator()

Expand Down
9 changes: 8 additions & 1 deletion gateway/gateway-controller/pkg/xds/translator.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ func convertServerHeaderTransformation(transformation string) hcm.HttpConnection
// This format is used by both Envoy routes and the policy engine for route matching
// It builds the full path by combining context, version, and path using ConstructFullPath
func GenerateRouteName(method, context, apiVersion, path, vhost string) string {
// Uppercase the method so route names and policy keys stay consistent with the
// case-sensitive :method matcher, whatever case the operation was declared in.
method = strings.ToUpper(method)
fullPath := ConstructFullPath(context, apiVersion, path)
return fmt.Sprintf("%s|%s|%s", method, fullPath, vhost)
}
Expand Down Expand Up @@ -225,7 +228,8 @@ func (t *Translator) translateRuntimeConfig(rdc *models.RuntimeDeployConfig) ([]
// createRouteFromRDC creates an Envoy route from a RuntimeDeployConfig Route.
func (t *Translator) createRouteFromRDC(routeKey string, rdcRoute *models.Route, rdc *models.RuntimeDeployConfig) *route.Route {
fullPath := rdcRoute.Path
method := rdcRoute.Method
// Envoy's :method matcher is case-sensitive; normalize so any-case methods still route.
method := strings.ToUpper(rdcRoute.Method)
operationPath := rdcRoute.OperationPath

// Determine path type
Expand Down Expand Up @@ -1771,6 +1775,9 @@ func (t *Translator) extractProviderName(cfg *models.StoredConfig, allConfigs []
// upstreamDefPaths maps upstream definition names to their URL paths for dynamic path rewriting.
func (t *Translator) createRoute(apiId, apiName, apiVersion, context, method, path, clusterName,
upstreamPath string, vhost string, apiKind string, templateHandle string, providerName string, hostRewrite *api.UpstreamHostRewrite, projectID string, timeoutCfg *resolvedTimeout, useClusterHeader bool, upstreamDefPaths map[string]string) *route.Route {
// Envoy's :method matcher is case-sensitive; normalize so any-case methods still route.
method = strings.ToUpper(method)

// Resolve version placeholder in context
context = strings.ReplaceAll(context, "$version", apiVersion)

Expand Down
64 changes: 64 additions & 0 deletions gateway/gateway-controller/pkg/xds/translator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,70 @@ func TestTranslator_MCPAppendResourcePathToBackend(t *testing.T) {
}
}

func TestTranslator_NormalizesMethodCase(t *testing.T) {
logger := createTestLogger()
routerCfg := testRouterConfig()
cfg := testConfig()
translator := NewTranslator(logger, routerCfg, nil, cfg)

// A lower- or mixed-case method must be emitted uppercased into Envoy's
// case-sensitive :method matcher, otherwise uppercase client requests 404.
tests := []struct {
name string
method string
expected string
}{
{"lowercase get", "get", "GET"},
{"mixed-case get", "gEt", "GET"},
{"uppercase get", "GET", "GET"},
{"lowercase post", "post", "POST"},
{"mixed-case delete", "Delete", "DELETE"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rdc := &models.RuntimeDeployConfig{
UpstreamClusters: map[string]*models.UpstreamCluster{
"main": {Endpoints: []models.Endpoint{{Host: "echo", Port: 80}}},
},
}
rdcRoute := &models.Route{
Method: tt.method,
Path: "/api/v1.0/users",
OperationPath: "/users",
AutoHostRewrite: true,
Upstream: models.RouteUpstream{ClusterKey: "main"},
}
r := translator.createRouteFromRDC(tt.method+"|/api/v1.0/users|", rdcRoute, rdc)
require.NotNil(t, r)
require.NotEmpty(t, r.Match.Headers)
assert.Equal(t, ":method", r.Match.Headers[0].Name)
assert.Equal(t, tt.expected, r.Match.Headers[0].GetStringMatch().GetExact(),
"the :method matcher must be uppercased so Envoy routes uppercase client requests")
})
}
}

func TestGenerateRouteName_UppercasesMethod(t *testing.T) {
// The method segment must be uppercased so Envoy route names and policy-engine
// keys agree, regardless of the case the operation was declared in.
tests := []struct {
name string
method string
want string
}{
{"lowercase", "get", "GET|/api/v1.0/users|api.example.com"},
{"mixed case", "pOsT", "POST|/api/v1.0/users|api.example.com"},
{"already uppercase", "DELETE", "DELETE|/api/v1.0/users|api.example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want,
GenerateRouteName(tt.method, "/api/$version", "v1.0", "/users", "api.example.com"))
})
}
}

func TestTranslator_SanitizeClusterName(t *testing.T) {
logger := createTestLogger()
routerCfg := testRouterConfig()
Expand Down
123 changes: 123 additions & 0 deletions gateway/it/features/http-method-case.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# --------------------------------------------------------------------
# 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.
# --------------------------------------------------------------------

@http-method-case
Feature: Operation HTTP methods are normalized to uppercase
As an API developer
I want a lower- or mixed-case operation method to be accepted and routed
So that a method-case typo does not silently deploy and then 404 at runtime

Background:
Given the gateway services are running
And I authenticate using basic auth as "admin"

Scenario: A lowercase method is accepted and routes as uppercase
When I deploy this API configuration:
"""
apiVersion: gateway.api-platform.wso2.com/v1
kind: RestApi
metadata:
name: method-lower-v1.0
spec:
displayName: Method-Lower
version: v1.0
context: /method-lower/$version
vhosts:
main: method-case.local
upstream:
main:
url: http://sample-backend:9080
operations:
- method: get
path: /whoami
"""
Then the response should be successful
And I wait for the endpoint "http://localhost:8080/method-lower/v1.0/whoami" to be ready with host "method-case.local"

When I clear all headers
And I set request host to "method-case.local"
And I send a GET request to "http://localhost:8080/method-lower/v1.0/whoami"
Then the response should be successful
And the response should be valid JSON
And the JSON response field "path" should be "/whoami"

Scenario: A mixed-case method is accepted and routes as uppercase
When I deploy this API configuration:
"""
apiVersion: gateway.api-platform.wso2.com/v1
kind: RestApi
metadata:
name: method-mixed-v1.0
spec:
displayName: Method-Mixed
version: v1.0
context: /method-mixed/$version
vhosts:
main: method-case.local
upstream:
main:
url: http://sample-backend:9080
operations:
- method: gEt
path: /whoami
"""
Then the response should be successful
And I wait for the endpoint "http://localhost:8080/method-mixed/v1.0/whoami" to be ready with host "method-case.local"

When I clear all headers
And I set request host to "method-case.local"
And I send a GET request to "http://localhost:8080/method-mixed/v1.0/whoami"
Then the response should be successful
And the JSON response field "path" should be "/whoami"

Scenario: Two methods on one path, mixed case, each routes
When I deploy this API configuration:
"""
apiVersion: gateway.api-platform.wso2.com/v1
kind: RestApi
metadata:
name: method-multi-v1.0
spec:
displayName: Method-Multi
version: v1.0
context: /method-multi/$version
vhosts:
main: method-case.local
upstream:
main:
url: http://sample-backend:9080
operations:
- method: get
path: /resource
- method: Post
path: /resource
"""
Then the response should be successful
And I wait for the endpoint "http://localhost:8080/method-multi/v1.0/resource" to be ready with host "method-case.local"

When I clear all headers
And I set request host to "method-case.local"
And I send a GET request to "http://localhost:8080/method-multi/v1.0/resource"
Then the response should be successful
And the JSON response field "path" should be "/resource"

When I clear all headers
And I set request host to "method-case.local"
And I send a POST request to "http://localhost:8080/method-multi/v1.0/resource"
Then the response should be successful
And the JSON response field "path" should be "/resource"
1 change: 1 addition & 0 deletions gateway/it/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ func getFeaturePaths() []string {
"features/llm-provider-wide-ratelimit.feature",
"features/log-message.feature",
"features/route-path-matching.feature",
"features/http-method-case.feature",
"features/secrets.feature",
"features/template-functions.feature",
// Runs late: it restarts the gateway-controller (reject/reconnect scenario), so keep it
Expand Down
Loading