From f02662475afaa4ec41c1d4d02751c4f71a8b05f9 Mon Sep 17 00:00:00 2001 From: shivay Date: Wed, 10 Jun 2026 11:44:09 +0530 Subject: [PATCH] fix: Added changes to handle headers as case-insensitive --- ...ith-headers-case-insensitive.expected.json | 82 ++++++++++++ ...-routes-with-headers-case-insensitive.yaml | 27 ++++ ...-with-duplicate-header-names.expected.json | 126 ++++++++++++++++++ ...38-routes-with-duplicate-header-names.yaml | 34 +++++ openapi2kong/openapi2kong.go | 99 +++++++++++--- openapitools/utils.go | 42 ++++++ openapitools/utils_test.go | 71 ++++++++++ 7 files changed, 464 insertions(+), 17 deletions(-) create mode 100644 openapi2kong/oas3_testfiles/30-routes-with-headers-case-insensitive.expected.json create mode 100644 openapi2kong/oas3_testfiles/30-routes-with-headers-case-insensitive.yaml create mode 100644 openapi2kong/oas3_testfiles/38-routes-with-duplicate-header-names.expected.json create mode 100644 openapi2kong/oas3_testfiles/38-routes-with-duplicate-header-names.yaml diff --git a/openapi2kong/oas3_testfiles/30-routes-with-headers-case-insensitive.expected.json b/openapi2kong/oas3_testfiles/30-routes-with-headers-case-insensitive.expected.json new file mode 100644 index 0000000..305f54d --- /dev/null +++ b/openapi2kong/oas3_testfiles/30-routes-with-headers-case-insensitive.expected.json @@ -0,0 +1,82 @@ +{ + "_format_version": "3.0", + "services": [ + { + "host": "api.example.com", + "id": "62aa9ceb-0ca2-5c75-a775-e85bf8135fcb", + "name": "case-insensitive-header-test", + "path": "/", + "plugins": [], + "port": 443, + "protocol": "https", + "routes": [ + { + "headers": { + "X-Region": [ + "us-east" + ] + }, + "id": "b6e83bfa-aa2c-5196-82a9-a23d2e28764c", + "methods": [ + "GET" + ], + "name": "case-insensitive-header-test_get-regions_0", + "paths": [ + "~/regions$" + ], + "plugins": [], + "regex_priority": 200, + "strip_path": false, + "tags": [ + "OAS3_import", + "OAS3file_30-routes-with-headers-case-insensitive.yaml" + ] + }, + { + "headers": { + "X-Region": [ + "us-west" + ] + }, + "id": "885d905b-49cd-5d2a-a729-e092a3c1a499", + "methods": [ + "GET" + ], + "name": "case-insensitive-header-test_get-regions_1", + "paths": [ + "~/regions$" + ], + "plugins": [], + "regex_priority": 200, + "strip_path": false, + "tags": [ + "OAS3_import", + "OAS3file_30-routes-with-headers-case-insensitive.yaml" + ] + }, + { + "id": "cf479876-5229-5791-91e6-05cb13284856", + "methods": [ + "GET" + ], + "name": "case-insensitive-header-test_get-regions", + "paths": [ + "~/regions$" + ], + "plugins": [], + "regex_priority": 200, + "strip_path": false, + "tags": [ + "OAS3_import", + "OAS3file_30-routes-with-headers-case-insensitive.yaml" + ] + } + ], + "tags": [ + "OAS3_import", + "OAS3file_30-routes-with-headers-case-insensitive.yaml" + ] + } + ], + "upstreams": [] +} \ No newline at end of file diff --git a/openapi2kong/oas3_testfiles/30-routes-with-headers-case-insensitive.yaml b/openapi2kong/oas3_testfiles/30-routes-with-headers-case-insensitive.yaml new file mode 100644 index 0000000..a201baa --- /dev/null +++ b/openapi2kong/oas3_testfiles/30-routes-with-headers-case-insensitive.yaml @@ -0,0 +1,27 @@ +openapi: '3.0.0' +info: + title: Case Insensitive Header Test + version: v1 +servers: + - url: https://api.example.com/ +paths: + /regions: + get: + operationId: get-regions + summary: Get regions + parameters: + - in: header + name: X-Region + required: true + schema: + type: string + enum: + - us-east + - US-EAST + - Us-East + - us-west + - US-WEST + responses: + '200': + description: Success + diff --git a/openapi2kong/oas3_testfiles/38-routes-with-duplicate-header-names.expected.json b/openapi2kong/oas3_testfiles/38-routes-with-duplicate-header-names.expected.json new file mode 100644 index 0000000..e73f2c3 --- /dev/null +++ b/openapi2kong/oas3_testfiles/38-routes-with-duplicate-header-names.expected.json @@ -0,0 +1,126 @@ +{ + "_format_version": "3.0", + "services": [ + { + "host": "api.example.com", + "id": "2cded145-1222-5f52-8432-399da883ddf7", + "name": "duplicate-header-name-test", + "path": "/", + "plugins": [], + "port": 443, + "protocol": "https", + "routes": [ + { + "headers": { + "X-Env": [ + "prod" + ] + }, + "id": "bf4543a7-b775-55cf-bf25-8f87e31e37d0", + "methods": [ + "GET" + ], + "name": "duplicate-header-name-test_get-test_0", + "paths": [ + "~/test$" + ], + "plugins": [], + "regex_priority": 200, + "strip_path": false, + "tags": [ + "OAS3_import", + "OAS3file_38-routes-with-duplicate-header-names.yaml" + ] + }, + { + "headers": { + "X-Env": [ + "staging" + ] + }, + "id": "a750ed92-977d-5c68-a885-79c110a16fd4", + "methods": [ + "GET" + ], + "name": "duplicate-header-name-test_get-test_1", + "paths": [ + "~/test$" + ], + "plugins": [], + "regex_priority": 200, + "strip_path": false, + "tags": [ + "OAS3_import", + "OAS3file_38-routes-with-duplicate-header-names.yaml" + ] + }, + { + "headers": { + "X-Env": [ + "dev" + ] + }, + "id": "2af97532-3406-5aac-b76d-e23f33126bad", + "methods": [ + "GET" + ], + "name": "duplicate-header-name-test_get-test_2", + "paths": [ + "~/test$" + ], + "plugins": [], + "regex_priority": 200, + "strip_path": false, + "tags": [ + "OAS3_import", + "OAS3file_38-routes-with-duplicate-header-names.yaml" + ] + }, + { + "headers": { + "X-Env": [ + "qa" + ] + }, + "id": "95b8e3f9-ceba-518e-9553-4453065fc55a", + "methods": [ + "GET" + ], + "name": "duplicate-header-name-test_get-test_3", + "paths": [ + "~/test$" + ], + "plugins": [], + "regex_priority": 200, + "strip_path": false, + "tags": [ + "OAS3_import", + "OAS3file_38-routes-with-duplicate-header-names.yaml" + ] + }, + { + "id": "59f4e76e-67d9-5a0c-bd92-2c2413b23fd3", + "methods": [ + "GET" + ], + "name": "duplicate-header-name-test_get-test", + "paths": [ + "~/test$" + ], + "plugins": [], + "regex_priority": 200, + "strip_path": false, + "tags": [ + "OAS3_import", + "OAS3file_38-routes-with-duplicate-header-names.yaml" + ] + } + ], + "tags": [ + "OAS3_import", + "OAS3file_38-routes-with-duplicate-header-names.yaml" + ] + } + ], + "upstreams": [] +} \ No newline at end of file diff --git a/openapi2kong/oas3_testfiles/38-routes-with-duplicate-header-names.yaml b/openapi2kong/oas3_testfiles/38-routes-with-duplicate-header-names.yaml new file mode 100644 index 0000000..51ba1f9 --- /dev/null +++ b/openapi2kong/oas3_testfiles/38-routes-with-duplicate-header-names.yaml @@ -0,0 +1,34 @@ +openapi: '3.0.0' +info: + title: Duplicate Header Name Test + version: v1 +servers: + - url: https://api.example.com/ +paths: + /test: + get: + operationId: get-test + summary: Test duplicate header names with different case + parameters: + # First header: X-Env with enum [prod, staging] + - in: header + name: X-Env + required: true + schema: + type: string + enum: + - prod + - staging + # Second header: x-env (same header, different case) with enum [dev, qa] + - in: header + name: x-env + required: true + schema: + type: string + enum: + - dev + - qa + responses: + '200': + description: Success + diff --git a/openapi2kong/openapi2kong.go b/openapi2kong/openapi2kong.go index 798fd5f..af9a8d8 100644 --- a/openapi2kong/openapi2kong.go +++ b/openapi2kong/openapi2kong.go @@ -491,27 +491,89 @@ func findParameterSchema( return nil } -// Returns header parameters which can be used for routing for an operation +// headerParamData holds normalized header parameter data for routing. +// This is used to merge headers with the same name (case-insensitively). +type headerParamData struct { + Name string + EnumValues []any +} + +// Returns header parameters which can be used for routing for an operation. +// Headers with the same name (case-insensitively) at the SAME level are merged. +// Operation-level parameters OVERRIDE path-level parameters per OpenAPI spec. +// This handles cases like "X-Env" and "x-env" being defined separately. func findHeaderParamsForRouting( operationLevelParameters []*v3.Parameter, pathLevelParameters []*v3.Parameter, treatAllHeadersAsRequired bool, -) []*v3.Parameter { - headerParamProcessed := make(map[string]bool) - var result []*v3.Parameter // Store in array so output is deterministic - iterating over map is not. - +) []headerParamData { + // Map to collect enum values by normalized (lowercase) header name + headerEnumMap := make(map[string][]any) + // Map to preserve the original header name (first occurrence) + headerOriginalName := make(map[string]string) + // Track order of first occurrence for deterministic output + headerOrder := make([]string, 0) + // Track which headers were defined at operation level (these override path level) + operationLevelHeaders := make(map[string]bool) + + // Process operation level parameters first for _, param := range operationLevelParameters { - if shouldAddHeaderParameter(param, treatAllHeadersAsRequired) { - headerParamProcessed[param.Name] = true - result = append(result, param) + if !shouldAddHeaderParameter(param, treatAllHeadersAsRequired) { + continue + } + + normalizedName := strings.ToLower(param.Name) + + var enumValues []any + for _, enumMember := range param.Schema.Schema().Enum { + enumValues = append(enumValues, enumMember.Value) } + + if _, exists := headerEnumMap[normalizedName]; !exists { + headerOrder = append(headerOrder, normalizedName) + headerEnumMap[normalizedName] = enumValues + headerOriginalName[normalizedName] = param.Name + } else { + headerEnumMap[normalizedName] = append(headerEnumMap[normalizedName], enumValues...) + } + operationLevelHeaders[normalizedName] = true } + // Process path level parameters (only if not already defined at operation level) for _, param := range pathLevelParameters { - // Operation level params override path level params, so ignore if already present. - if shouldAddHeaderParameter(param, treatAllHeadersAsRequired) && !headerParamProcessed[param.Name] { - headerParamProcessed[param.Name] = true - result = append(result, param) + if !shouldAddHeaderParameter(param, treatAllHeadersAsRequired) { + continue + } + + normalizedName := strings.ToLower(param.Name) + + if operationLevelHeaders[normalizedName] { + continue + } + + var enumValues []any + for _, enumMember := range param.Schema.Schema().Enum { + enumValues = append(enumValues, enumMember.Value) + } + + if _, exists := headerEnumMap[normalizedName]; !exists { + headerOrder = append(headerOrder, normalizedName) + headerEnumMap[normalizedName] = enumValues + headerOriginalName[normalizedName] = param.Name + } else { + headerEnumMap[normalizedName] = append(headerEnumMap[normalizedName], enumValues...) + } + } + + // Build result with deduplicated and normalized enum values + result := make([]headerParamData, 0, len(headerOrder)) + for _, normalizedName := range headerOrder { + enumValues := openapitools.DeduplicateHeaderEnumValues(headerEnumMap[normalizedName]) + if len(enumValues) > 0 { + result = append(result, headerParamData{ + Name: headerOriginalName[normalizedName], // Use original name + EnumValues: enumValues, + }) } } @@ -526,15 +588,18 @@ func shouldAddHeaderParameter(param *v3.Parameter, treatAllHeadersAsRequired boo return param.In == "header" && hasEnum && isRequired } -// Based on given headers and their possible values, create all possible combinations -func constructHeaderCombinationsForRouting(headers []*v3.Parameter) []map[string]any { +// Based on given headers and their possible values, create all possible combinations. +// Header names preserve their original case, enum values are normalized to lowercase. +func constructHeaderCombinationsForRouting(headers []headerParamData) []map[string]any { + if len(headers) == 0 { + return nil + } + headerValues := make([][]any, len(headers)) headerNames := make([]string, len(headers)) for i := 0; i < len(headers); i++ { headerNames[i] = headers[i].Name - for _, enumMember := range headers[i].Schema.Schema().Enum { - headerValues[i] = append(headerValues[i], enumMember.Value) - } + headerValues[i] = headers[i].EnumValues } headerValueCombinations := openapitools.CrossProduct(headerValues...) diff --git a/openapitools/utils.go b/openapitools/utils.go index 64e41dc..6846919 100644 --- a/openapitools/utils.go +++ b/openapitools/utils.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/kong/go-apiops/jsonbasics" + "github.com/kong/go-apiops/logbasics" "github.com/kong/go-slugify" v3 "github.com/pb33f/libopenapi/datamodel/high/v3" "github.com/pb33f/libopenapi/orderedmap" @@ -232,3 +233,44 @@ func GetRouteDefaults( ) ([]byte, error) { return GetXKongObject(extensions, "x-kong-route-defaults", components) } + +// DeduplicateHeaderEnumValues normalizes and deduplicates header enum values. +// String values are converted to lowercase and deduplicated since HTTP header +// values are often treated as case-insensitive for routing purposes by Kong Gateway. +// Empty strings are filtered out with a warning as they are not practical for routing. +// Non-string values (e.g., integers) are preserved as-is. +// Returns the normalized and deduplicated slice. +func DeduplicateHeaderEnumValues(values []any) []any { + seen := make(map[string]bool) + result := make([]any, 0, len(values)) + emptyStringFound := false + + for _, v := range values { + strVal, ok := v.(string) + if !ok { + // For non-string values, include as-is (e.g., integers in enum) + result = append(result, v) + continue + } + + // Filter out empty strings - they are not practical for header routing + if strVal == "" { + emptyStringFound = true + continue + } + + // Normalize to lowercase for consistent routing + lowerVal := strings.ToLower(strVal) + if !seen[lowerVal] { + seen[lowerVal] = true + result = append(result, lowerVal) + } + } + + if emptyStringFound { + logbasics.Info("empty string enum value filtered out from header parameter" + + " - empty strings are not practical for header-based routing") + } + + return result +} diff --git a/openapitools/utils_test.go b/openapitools/utils_test.go index b1279f5..f5c96bb 100644 --- a/openapitools/utils_test.go +++ b/openapitools/utils_test.go @@ -73,3 +73,74 @@ func Test_ToKebabCase(t *testing.T) { }) } } + +func Test_DeduplicateHeaderEnumValues(t *testing.T) { + testCases := []struct { + name string + input []any + expected []any + }{ + { + name: "No duplicates - already lowercase", + input: []any{"us-east", "us-west", "eu-central"}, + expected: []any{"us-east", "us-west", "eu-central"}, + }, + { + name: "Case-insensitive duplicates - normalized to lowercase", + input: []any{"us-east", "US-EAST", "Us-East"}, + expected: []any{"us-east"}, + }, + { + name: "Mixed case duplicates and unique - all normalized to lowercase", + input: []any{"us-east", "US-EAST", "us-west", "US-WEST", "eu-central"}, + expected: []any{"us-east", "us-west", "eu-central"}, + }, + { + name: "Non-string values preserved", + input: []any{1, 2, 3, "test", "TEST"}, + expected: []any{1, 2, 3, "test"}, + }, + { + name: "Mixed types with duplicates", + input: []any{"v1", "V1", 1, "v2", 2}, + expected: []any{"v1", 1, "v2", 2}, + }, + { + name: "Uppercase values normalized to lowercase", + input: []any{"US-EAST", "us-east", "Us-East"}, + expected: []any{"us-east"}, + }, + { + name: "All uppercase converted to lowercase", + input: []any{"US-EAST", "US-WEST"}, + expected: []any{"us-east", "us-west"}, + }, + { + name: "Empty string filtered out", + input: []any{"", "prod", "staging"}, + expected: []any{"prod", "staging"}, + }, + { + name: "Only empty string returns empty slice", + input: []any{""}, + expected: []any{}, + }, + { + name: "Multiple empty strings filtered out", + input: []any{"", "prod", "", "staging", ""}, + expected: []any{"prod", "staging"}, + }, + { + name: "Empty string with duplicates", + input: []any{"", "PROD", "prod", ""}, + expected: []any{"prod"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := DeduplicateHeaderEnumValues(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +}