From 7b37d586756d7fcd063f92263e003d703e3ef48d Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Mon, 13 Jul 2026 06:54:10 +0530 Subject: [PATCH 1/9] feat(gateway): add per-operation upstream override for REST API operations --- .../api/management-openapi.yaml | 43 +- .../pkg/api/management/generated.go | 546 ++++++++------- .../pkg/config/api_validator.go | 112 ++- .../pkg/config/validator_test.go | 391 +++++++++++ .../pkg/transform/llm_test.go | 63 ++ .../pkg/transform/restapi.go | 195 ++++-- .../pkg/transform/restapi_test.go | 662 +++++++++++++++++- .../pkg/utils/clusterkey/clusterkey.go | 57 ++ .../pkg/utils/clusterkey/clusterkey_test.go | 100 +++ .../pkg/utils/upstreamref/upstreamref.go | 97 +++ .../pkg/utils/upstreamref/upstreamref_test.go | 203 ++++++ .../gateway-controller/pkg/xds/translator.go | 208 ++++-- .../pkg/xds/translator_test.go | 455 ++++++++++-- .../tests/integration/storage_test.go | 94 +++ .../internal/kernel/translator_test.go | 93 +++ .../it/features/api-level-url-stable.feature | 484 +++++++++++++ .../it/features/per-op-upstream-basic.feature | 474 +++++++++++++ .../it/features/per-op-upstream-ref.feature | 307 ++++++++ .../per-op-upstream-validation.feature | 202 ++++++ gateway/it/steps_envoy_admin.go | 118 ++++ gateway/it/suite_test.go | 5 + .../data-model.md | 8 +- 22 files changed, 4408 insertions(+), 509 deletions(-) create mode 100644 gateway/gateway-controller/pkg/transform/llm_test.go create mode 100644 gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go create mode 100644 gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go create mode 100644 gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go create mode 100644 gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go create mode 100644 gateway/it/features/api-level-url-stable.feature create mode 100644 gateway/it/features/per-op-upstream-basic.feature create mode 100644 gateway/it/features/per-op-upstream-ref.feature create mode 100644 gateway/it/features/per-op-upstream-validation.feature create mode 100644 gateway/it/steps_envoy_admin.go diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 454eba734d..d88f025a74 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -3016,6 +3016,14 @@ components: default: deployed example: deployed + UpstreamReference: + type: string + description: Name of a predefined upstreamDefinition. + minLength: 1 + maxLength: 100 + pattern: '^[a-zA-Z0-9\-_]+$' + example: my-upstream-1 + UpstreamDefinition: type: object required: @@ -3024,12 +3032,7 @@ components: description: Reusable upstream configuration with optional timeout and load balancing settings properties: name: - type: string - description: Unique identifier for this upstream definition - minLength: 1 - maxLength: 100 - pattern: '^[a-zA-Z0-9\-_]+$' - example: my-upstream-1 + $ref: "#/components/schemas/UpstreamReference" basePath: type: string description: Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. Must start with '/' and must not end with '/'; omit for root. @@ -3100,8 +3103,7 @@ components: description: Direct backend URL to route traffic to example: http://prod-backend:5000/api/v2 ref: - type: string - description: Reference to a predefined upstreamDefinition + $ref: "#/components/schemas/UpstreamReference" hostRewrite: type: string enum: @@ -3140,6 +3142,8 @@ components: $ref: "#/components/schemas/Policy" resilience: $ref: "#/components/schemas/Resilience" + upstream: + $ref: "#/components/schemas/OperationUpstream" OperationMethod: type: string @@ -3209,6 +3213,29 @@ components: enum: [Exact, RegularExpression] default: Exact + OperationUpstream: + type: object + additionalProperties: false + description: Per-operation upstream override. Each sub-field must reference a named entry in spec.upstreamDefinitions. Missing sub-fields fall back to API-level upstream. At least one of main or sandbox must be set. + minProperties: 1 + properties: + main: + type: object + additionalProperties: false + required: + - ref + properties: + ref: + $ref: "#/components/schemas/UpstreamReference" + sandbox: + type: object + additionalProperties: false + required: + - ref + properties: + ref: + $ref: "#/components/schemas/UpstreamReference" + Policy: type: object required: diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 5ab19469d7..4a3739a03f 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -788,8 +788,8 @@ type LLMProviderConfigData_Upstream struct { // HostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. HostRewrite *LLMProviderConfigDataUpstreamHostRewrite `json:"hostRewrite,omitempty" yaml:"hostRewrite,omitempty"` - // Ref Reference to a predefined upstreamDefinition - Ref *string `json:"ref,omitempty" yaml:"ref,omitempty"` + // Ref Name of a predefined upstreamDefinition. + Ref *UpstreamReference `json:"ref,omitempty" yaml:"ref,omitempty"` // Url Direct backend URL to route traffic to Url *string `json:"url,omitempty" yaml:"url,omitempty"` @@ -1127,8 +1127,8 @@ type MCPProxyConfigData_Upstream struct { // HostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. HostRewrite *MCPProxyConfigDataUpstreamHostRewrite `json:"hostRewrite,omitempty" yaml:"hostRewrite,omitempty"` - // Ref Reference to a predefined upstreamDefinition - Ref *string `json:"ref,omitempty" yaml:"ref,omitempty"` + // Ref Name of a predefined upstreamDefinition. + Ref *UpstreamReference `json:"ref,omitempty" yaml:"ref,omitempty"` // Url Direct backend URL to route traffic to Url *string `json:"url,omitempty" yaml:"url,omitempty"` @@ -1239,6 +1239,9 @@ type Operation struct { // Resilience Backend/route timeout configuration. Maps to Envoy RouteAction timeouts. Can be set at the API level (applies to all routes) and/or the operation level (applies to that operation's route). When set at both levels, the operation-level value takes precedence. When unset, the gateway's global route timeout defaults apply. Resilience *Resilience `json:"resilience,omitempty" yaml:"resilience,omitempty"` + + // Upstream Per-operation upstream override. Each sub-field must reference a named entry in spec.upstreamDefinitions. Missing sub-fields fall back to API-level upstream. At least one of main or sandbox must be set. + Upstream *OperationUpstream `json:"upstream,omitempty" yaml:"upstream,omitempty"` } // OperationHeaderMatch defines model for OperationHeaderMatch. @@ -1302,6 +1305,18 @@ type OperationPolicyPath struct { // OperationPolicyPathMethods HTTP method: GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, or * for all type OperationPolicyPathMethods string +// OperationUpstream Per-operation upstream override. Each sub-field must reference a named entry in spec.upstreamDefinitions. Missing sub-fields fall back to API-level upstream. At least one of main or sandbox must be set. +type OperationUpstream struct { + Main *struct { + // Ref Name of a predefined upstreamDefinition. + Ref UpstreamReference `json:"ref" yaml:"ref"` + } `json:"main,omitempty" yaml:"main,omitempty"` + Sandbox *struct { + // Ref Name of a predefined upstreamDefinition. + Ref UpstreamReference `json:"ref" yaml:"ref"` + } `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` +} + // Policy defines model for Policy. type Policy struct { // ExecutionCondition Expression controlling conditional execution of the policy @@ -1638,8 +1653,8 @@ type Upstream struct { // HostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. HostRewrite *UpstreamHostRewrite `json:"hostRewrite,omitempty" yaml:"hostRewrite,omitempty"` - // Ref Reference to a predefined upstreamDefinition - Ref *string `json:"ref,omitempty" yaml:"ref,omitempty"` + // Ref Name of a predefined upstreamDefinition. + Ref *UpstreamReference `json:"ref,omitempty" yaml:"ref,omitempty"` // Url Direct backend URL to route traffic to Url *string `json:"url,omitempty" yaml:"url,omitempty"` @@ -1674,8 +1689,8 @@ type UpstreamDefinition struct { // BasePath Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. Must start with '/' and must not end with '/'; omit for root. BasePath *string `json:"basePath,omitempty" yaml:"basePath,omitempty"` - // Name Unique identifier for this upstream definition - Name string `json:"name" yaml:"name"` + // Name Name of a predefined upstreamDefinition. + Name UpstreamReference `json:"name" yaml:"name"` // Timeout Timeout configuration for upstream requests Timeout *UpstreamTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` @@ -1690,6 +1705,9 @@ type UpstreamDefinition struct { } `json:"upstreams" yaml:"upstreams"` } +// UpstreamReference Name of a predefined upstreamDefinition. +type UpstreamReference = string + // UpstreamTimeout Timeout configuration for upstream requests type UpstreamTimeout struct { // Connect Connection timeout duration (e.g., "5s", "500ms") @@ -4561,261 +4579,263 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+y9+3bbNr4/+ioY/bpX7FaUZTtJG2fNmuPYbqpJnHh8afeZyruBSMhCQ4EsADpWM97r", - "PMR5wvMkZ+FKkAQpypavdf9oEpEEvgC+d3zwxddOmEzThCDCWWfra4eFEzSF8q/bB4OdhIzx2S7kUPyQ", - "0iRFlGMkH4cJ4eiCi79GiIUUpxwnpLPVeQMZAinkEzBOKIBxDLYPBoAmGUcMrEwzxgHjkHLwBfMJWOsC", - "kgBOIY4xOQMshmyy2gMnDIFvzhFlOCGAJwBNRygCfIKA+RET+U/Z0QrqnfW6YI0iGGFyFsSY8TX7OUUs", - "ic8RE+0UXzlf7/VXe51uB13AaRqjzlbH30an25nCi/eInPFJZ2uj3+92ppiYf693OynkHFEx/P8ZDtdW", - "foXBn9vBv/vBq9+Gw2A4XDv99lfx4HT1H990uh0+S0VfjFNMzjqX3U6E0jiZTRHhRxxypCZ1DLOYd7b0", - "QxR1uqWZ3kUMUxSB/GsxsxyBADwzHz0DK7qlVZBQ8Cwj9kkP/DJBBDDExcy4T7pyasWyYQYomibnKAJj", - "mkzVMlKxXuMxDsEo4yCUTJJRKKjqyq8+oxnrAkgikCYxDjFiAFIEUooYorKthII04YhwDGNAUT4CuRok", - "m3a2fnUHnhPXOXWXy3mlOqmYpTGcfYBTVOXSn7IpJIFYbDiK1VgJnCLNoCMETg7fB2OKEYniGQhAQuIZ", - "iJFYZdYFJJuO5F9YCkPEumAySyeIsC4QhFIWJhTpGYgSzoQUJF9QtFpgtUPFaeA9ZlwQUGSy9UYmyxls", - "OAx+Gw574PQ7L2cJkZUrw6pzIDtOxuCn4+MDkL+4pmS10+1gjqbyu28oGne2Ov9nLdcWa1pVrH00H4ru", - "ppgM1EfrlhhIKZyJh4YZ6inZPhgEMTpHscM4aRpjIfuJ1CU5mSAjMWIMJOeIUhxFiLSl+EC0LSkqU0gR", - "wzFGJETz2jjM37zsdlg2ssM5iGHTZLuvgjSGRPIdA/Ac4ljyohAOPsFM84RlmF87b5NYcPoRjs8RFYJg", - "h1tZ9/LIspRxiuC0Slg+5+adokh3uiXNP4WYzJueE9OdmBxIolFy0f4TuRB/ZEK3iVHL/k7tkJLR7yjk", - "7ph20RgTPIfJKcqYnF47yij/TNmiRH4DY8DxFCVl1dZaIE4qZPkWxFiWCsFHaAoJx6G1dMnYqOOC+hDG", - "q1NQCufDYfTdcNgTf3iVwfkkYdwzRzsZ48kUnGPKMxgD+dZalIiJZ5odTf9+Vpjb3Apb1Q2usFWl/mkS", - "ZaGUAm1NeuAjQcJITROK5FdSMoaEoRRSyFEERjPw7PUz8P/9P/8vQDCc2JeAtCtM0ik6yRdZugbgizB0", - "ELyFHH2BMzGUIRFa71BoOgA5h+FEOQjTLOY4jREQ9h8RRHNCVnvgeILAGFPGASKczoR5lE4IxVNIZ0Mi", - "J7gH9gq0TeFMGBQIvuA4CiGNAMvCCYAMfNvTy9kLk2lvSArrC1PsPn4dJSEr/FD4usgJK8Pht8Nhb/Uf", - "uZ3oDYfB6XcrwyH79rX4X+0rq996eceR4rmrrZdarrP+zixyYYj6WVAaaqfW1CkKPfS1Uxmlt1wHIRfI", - "rnVtHa1ZMKQ+XbR9MHiHZtXZ2UUc4pgJIYbEOEfuJHwVCz2IOlsd1/MUUxJoCYcplk2Lv6S/rW9sPn/x", - "8vsfXvXhKIzQeNF/i/FRJKRpWziXG/2Nl0H/edBfP17vb232t/r9f+evvJHdRlMspqXgT3X2Z+AgF+F3", - "elAppoiJhkkWx90OUe9OZ0Eu7oGaAJZkVJjZTpyEMBY/cMgzJvoLOT6XZrWobPQ8lWf4hOA/MgTSbBTj", - "EOBIOJVjjKijNwGfQC7/8RlJoYWMJSGWKkVo/gJT1i1DRSLMupQJeivUhmxbL7eyLnL1hA88xhdlQV/K", - "slYIdNa5TOMxniLG4TRVqtHMkyQWMnBmhlAgtIZXxgmdQhmoQI4CYTsbiHnjmbBBZc0yhij4MklyQlwS", - "i7OnufNa7r/U046hkxOxIqgQjHuOIxR1wTTj4uWiE+8Tg2YvvkKoIzVlMvfEI6iMpF2xFSFbAI9F4Izs", - "C6vlpfo+6K+LpeqLdWpaKtGcGFhni9MMeQkUuhjGh2jsE8A9/RhQNEZUuMRgsFuezQJ1YZxkkZCtqVAG", - "wasfvn/5wreExLt2IjJjcIxcWa+sHcx4EuTcI4NXhyO6AE/1enYFt0XCHMtcgnA1pogjWpxQnwpz1vnl", - "ZmGZNysWrB+8Ov1uJbB/rbOyWitWnEL5u6vS5Cil7hQuk1miVSd8NorVPCtGzuZplQSthyskyN9LJDjd", - "abUtTOx58lmrjlTa2kLH9r1mE06UVVZK31LlKjVXp7hSZGex3k7viA9xQg7RHxliUvAcg1xrtXwmyWsC", - "PppIIo0hJoHwJuyincM4U8rGLIyySkSQiBPSG5LBGORqR4aCyorEsXAkJbtiwjiCkVgOzeWYnAEICPoC", - "EoJ6Q3KszZ35bALZRLjQaCzca8YTCs+QcmnFayEk4i1MACQzoBTFkKxMMcHTbAo2X4JwAikMOaJMJ+gk", - "ZWIgmnZyZocUz3LVPSQmJ1R2cS/kf8EXlmxIS5vGkIuepVbQD9UfwmK68vXy+nq0BwZjMEr4BOgPB0Qm", - "bGwzOmdl1iH/ncPPiAlLHqJIqLte1UqubwT9H65gJS0pjWOIdEjqUbJF/jQvevxS04TLjqYDdzybfUsm", - "JhydISpDb4JrvAogHnna01qCoTAhEVPLqdNMkySj4s8IzsQfXxD6LF9ICJ+wUr5PvdKsOiRx3XzwPj2w", - "DJsmhUyIAEZxJNxKm0AQfCTFVH5BYShkI81omjDEZC5RC+iZjkiNsDCAOQPJFwLEZEsKTL8Uhp8xOSvL", - "UFtbihnLEG1wvnQom1AOY+Uwa/VqNZCUmFwgZEoUpliKNija2iERjTHhVukWjRqCYYhSjiLZGEl4QdMh", - "isQ8ksR8RZEYgdGLZbc5VxgROldf+IY+hewzirZrdPW+fOrJtki1KKZe+w12AXtDcqCJBqOZmjZNiPxO", - "utS5TkwpCrTy9SlB6f5/++23317M/vz+h1ft/aCBN9Qx61ScWgj0LoDrNJkl8Xv7t+LxXLYw0SxNCEMl", - "G51b3qfwuS58niLG4BlSOV7JzbmQsiwMEWPjLI5n0mebQkwwOVNS8q8s4bCz9cppVn/Q5AM1JUV1fsSl", - "ylnP+QRWZMJPcVlGDs1bVqD/EC9aTS5CPJfrX/mMXe4RO6krPR3zbJH1W82w653S95hxl9t90yz/2ioL", - "nU94OfO80HC6HZ5wGO8kGfEZfPFM74bp/Rup4woORHVK66X+EBlvtsY5r7Dfgl7fk6v2wFy1Jl45T8KK", - "jShtUDQpGx2ozlU1tyT/J6ngN4frYRx/HHe2fm0j6OWI9vK0SIfW0qeX3c6OmJ4xDiFHzSonzF9sr3ec", - "1m3LS1JCb2bct3mslNBIPJRp9jgGDuVgjGNUUEgbG+svXnkV/SKqrrGLljrPN1ceoI2Xng8+SpiBxQiK", - "XILWfcPF9dl0x0tcOTkZ7K5a/eX0VtClL1700Q/P+/0AbbwaBc/Xo+cB/H79ZfD8+cuXL148f97v9/uL", - "xCXO3AD1Dtj9AFYEGWoHThAC8BiMMhKVs7I7H/6+PwM7292P4s+P9AwS/KcCqOz8/eTIGyTkmqKU91Jc", - "CWSeQ5kGFeSZLwodO1RnaZxAESOIaPBo9whkUsDn6xu/uy8cR+Po1y3CdBaEcjsuCKG35YRvj/m86UaO", - "+RL/bjnpypquBxsvQf/lVv/7rY2XrY2pow6M9bHKAFGa0KJtadAULFPi1ThC/dJNctQceT+RzOEo+1rV", - "Wx3Jwd5+gEiYCN76796L/iuXH1bYag/sQALChHCISb6j7eqJYsoqEP+92Xs7+AB29g6PBz8OdraP9+Sv", - "Q7I/GOz+9/HOzvbnX862vwzebJ8N/rn97n3/5O1308N3/Pf97f7bnaM/3h4NRpu7/9p7s/PlZHt/7+Ri", - "58/tf745+/DzkPR6vSGRre192PX0sEDqX2mnwnaNM6we2NforUy9CEOaMFY2CaXRl4TmChis3m+tdqWL", - "UitH6PMG9gS/19sDKQ6sbqcZRcJNxJESX/1uS+DKz/ZDSYLPbNdqyZ/w2UTDiGSnwH1cECQXU+PSOpbU", - "t/W/lFJYive1d8EplLF1nlGpTjsuPCsO/p9HHz8cQJVJpoipPBIFEwQjRBW38sTYVJUw4slnpD36wvR8", - "08sEoT1M0owfi5e8Wi7Wnm+Vll9kEo0nYIxJ5HTl2C7Hx0/hTOgh4dlLYjvdzh8ZorMDSKHGYUzU3wv6", - "N/+sef4tmV13/nyL8P79/rbU6TsJ4TSJPXx/EaK0BuSlJ9+8IIYvRg6V5Q5Vk2CaRKitLEhk0J5p0SsK", - "orUqms7bpd0ji+Pky28wjiWWl8zkX0uAVv3rXIiLaLlmJjXAsTKFRqk6u4DxNAgTxoMRZCgKKOQoxlMZ", - "k1V4TvBC+zjAkiHWZg4AzgW1td0YzOE6iq7GqZA0eIJDPkmi4pDMSr3dO+50Owcfj+QfJ+L/u3vv9473", - "xD+3j3d+6nQ7Hw+OBx8/CNv/0972bqfb+dahoh6KKXeYVU4nirByJg8cwtQufFXDgCM5tVqzjjA50wh4", - "vWHNbG5dZaUxUyjaWQ/IbQrMGYrHEv4CCu0lYWag15UpTPXMOQj5cAK5XPEYGVxk84rJNrp2uu0M1C2Z", - "ylrTptMHsKwr5rBiUbdcdovHFwzSfq0CsV/CYYbi8YIkRQTiv+R5gvfv94FZ24UPFjyo0wSFkWp9lffy", - "y9HHDfAxRWR7YN+6Eez/WZyMYHxQi7p/K5+DFZhi5bqtVmH32oPefv/ehd5DBhKCAJtAwS8sTFLUBUh4", - "MwqmqzAG9oMSpr93faC+bbp+dB9relfkygMFLEWhcMilhLM1raDckUARLQM1kYvT/7FApXcgxTMRKUWh", - "3IjzGoHdvYPDPRE37YIAZMyZYDMLPXDEcRyDSUKSTCzNCtd7uMr9CiUygyfVL1dbDyr3L5Z4gIKjaRp7", - "g91j/cS60WLg9oiEK2kFIbN6tiIV7kmIdhlW5zBDuxe3M+HynN7+EYUeODR4BekEmIZ6FI17d3x+oXap", - "rnqQodr1zw4GXfGLBWEI+4LJWQ8cZWmaUM6EaSMRpBHQYHWJ8e8Clo30yYeuMHAWs69/1CmGcSI8eXD4", - "404gPSEMCc8R/zSLhSz+or9V9krBJdRBMJOmjdGYB1NBbQxHKDYHGQvI/lXfwQDF3hos77oSLzYbLIfG", - "/P8ntyCnK//YKtiT06/97sv1S+eN1X8Mh73V7/Qvp183upfzUx110Hor5wVsfdGba+UWOrtl7YS4rgW7", - "YdIt+5h52qFdD4dIbcoroKTcgSlLBj1HNJhCAs9QBGI8RuEsjJECELEeOEjSLJbqWh1blRkgaW6Ea/GR", - "xDNlGDzJxdPykYKfjXx2NMao5wJmel9YsiHYZ01GXJ8xiYQiiqeuQ4I4jLT3rZEIEqmneM8Ao9UOeYpC", - "r1vuBu2/OhHXryq0OjUBhieqEAvivC8CMud1Ef7G7V5a+yr/HESXcppU3J4H2m4wYPzzNbEKjFdQG1Wv", - "LTdcuckpWJhMxU86vbLVEbYhoTp3nMuRWBwNMc1o3NnqTDhP2dbaWlHaxXK5ylcpz0KOzAdP2Xh+3P9+", - "a2N9a33z352u9XOb3sFR3Xqrzkr+st7bqG/x8rJBjP043Ccufqhc3O2ozOZW5w2CFFHAPgezJKPB9djc", - "hz36uc4PsQGY8fJ1xtnaIhMYzuWsQozYgg8r7opizFoC5eOcHpd/C10XGduzg5lzepOd2jfvOSy/kOWU", - "KZmyxXeWQg/YoUh3NMeyHztBwMJG3Xz8ZM+9mvA4d7w8GlErQ6sGHM7IlZnejSjthdgdi/zF37jZt8i3", - "KeyWwaVfGyk81DTlc3pRL83rwYIDa1q7yDPdgX038DWqNZ5mdsT4vtDCHvKkdm4gSC3+1b6WuJQ5EyPf", - "aZ6XRd0E6QKUWeMKpt7wXl0hmSqDNYmld7vuCgm6QmKhEGBZjmwOrKr5Nppkqe80wRFXhR3gFMezQL6G", - "yZkLstGZtNEMoHNEZ8XYGbMhMfMv4k+9n6/f0ZkBi7fXVMgUaoTHMh3Ah2QCSRTr1CnL6BiG6tCbbSUZ", - "y5xe3tGuyvMywJMhMUqjJwNciXBPpphzFJXDU1+G+3nfC8uXetN3IvQjxWfYZg5ykt5kOOaBCJ71T0ym", - "g54J7ffsNVDb+PlkMYt65wl4pp4i+kzmkvWpfJ2thkQfzCqPRrRc5oQXvs2xkvK6Cgd7tNbVmikqqqu1", - "oWzfPkwFq7IFfITcEJea8KnBq9BW0oZXaaI2eWWVgnSmCbeCqOpIqIJRHhHUnCoEcEiMBIYShYMuMOOv", - "DSfKvWnRTKMM6ZSYw3Wb/UVSLi0drZsKu56cjSdnY7FgzcrdfQ3WLIH1wZrl+rqgzRGLuwjeCm7YDYZv", - "JcV/cx7fI7W5vlNM6ompUCAT+vkm2FRPdKmOoQ43O3P91vthlUsMaWfjalzHqmxnWlwMwzSHuSs7Y5e1", - "5F7Mtl28j8rjVAFk9h0ZpZj8pClSFSlsG2biycVMePAQMBSjkBe2DnvA7OwqaIUEIHIRYMgTzVSihxRK", - "7hNkn3TxQ9dJ+YSjT6s9YEsZwIxP9HYjwEztrJnTr5IU6dCEMI5VJYKUJhyFHEXAUYGyWp/8xp5vjpMk", - "HcHws6JTeUIlw+HbM03OcKjnqAC1sITZ/X6e6Amy8ybfroCFRRhlqoqKARUiIDkdjT4bJHxCkxSHgbOz", - "dUVQRw2gw6Rh5/BscRt6zjkPeWQGmEw+iOMpSH27tPnw0oYcJKeQMGFlFWvPF66L2bHzSVkJ4KhB/C9m", - "jQixiqyxhjodsd6Ch37pYw3i5xE+5kifChwgMYgfOUMx5AldlQGCkk5tP5iJRVU8wZBgZIY4N2A/iwwQ", - "rK6Kd+jic+CTIfaTLkgAR8m5aFmV8RNfm2jYtgI1RPjHd4BDeoa4YuoFlKNXqXngAk94u7vC213MHj/Y", - "TgnjbRfwzffRLmaLwDCeAHxPAL77CuBLHce0jfZ3df5VwX9XgpKlWuqecGR/RRxZ6myQz3EPr4gUK33+", - "tK1czvQqm1eb3lXyWcjtluApgRHhOnSKfJjr119Pi+qpHqF0iwip0lCuiYyqYbol5ucf1qotCvi5mN1n", - "tM/FzJ89vpj5UsYXs9vPExdC6uWmiB1XoRqr32Feowbi2GyW5uQljotZkHIyVwq1TdA6GQHrtOuizE62", - "ypSaV9kGFDmJvmObgVM159SLbqsMQBHg2dSGOhYIvkwShgC6QGEmhcW+AqaQq4L4LgldwGQOkWZE1TDM", - "a0e7VBoKLWHyyTPWmGcUH6aQMZNgKdOPxbsyQ5EP3JMpvMrRymPbUeCEE/ZM5YqqjyTZRR7njbsgl4RV", - "76FJ9cPX2o7MAqjJcDswe6NJYPNtpQuKPG/Mz/DXetj78PeEBnIxeYU8u/ftUni+ru9BMJcpaJxsjGh+", - "0xLmZhkxYRzGsfCeszg2TVb3uzsN7uZ5jf9eEkr5NB9rjYAWlEhFExmAa+2NKvkJ4RzrmvCJ1BkkIch7", - "AliDYSu6yt63QlGkEio98AvFHMkV2crP8wrhlE7LmvImZGKCoHM55zyjJM/FK7dRJpO2DwbiSwiEW9hV", - "GTUyAzSJUQ9sE12PRtX6TKZCxDAHmEwQxVqJMJ5QVVEiQ69lpumTGNwWEEP9JNZY5ZXk5kDPU8mj2/ki", - "xlP0SSuL5luq/Z2DA7m/5zEX9EweWG6V0DXvyuErCFAOW7bxcqkWm9tmtc5Gfn+PjkdNJ1cr5tP0dT5V", - "njoPgu0KLahEn/7CtjZKkhhBdYgL8xg1zNqkmFqTr88n03dA/7RWLea5hsZprqOpWFdksboxnoLxag/Z", - "u5Wx2FwRZ0VVo97asVefPSUQzZsetZfy7e8c6FywfgXoQ/lOqlwiD/lEie3DSHHnw3rUKe58mIadKpjV", - "PXfxln+UfP7VbTmN/gvcrp8yVlLVfvc+tyBLPKi8OIhgf+fApHy8hRBTFNaGtGJSawNat/Dai6D/Mlj/", - "oXDZh6eIYhIvRPdxooplNF0md7NHqCvO+gSBEQw/IxJJjpMSS0FGVc11B6Ngb237a57CzsXRxzF1Vxr9", - "pfPh0zBdL11D9oAy4lYm5/sOC2fEvZ8/ZcTz3Op+mPrTqrlPFUzDNLC56Gp2teB9FXOrBdtureCvpwVr", - "JP5ZsCWOWehY3S/ecrV3fghza80hYWuz37/Vg8a+ebpGNr2RYZeSTf/LrPhCKfjc6tzXNHxOoU4XGYLE", - "ghb6VCt8awl4T3i3rAS864Euluuwwe6cqHuKp+jYm/S0LewP9vfMnLeM2oWz54bVFhLsqxGK/2zqXTwW", - "zoGsEt7x1v6+erhv6GoZ8Hc7GcWL5Cjqx12upk9xU2FZ49EvxgM/1eZfxPjHGQnVDGHu3bCShUxVpUF/", - "4dS8rOFY3dSBLlK1xZFn4ZeR6RH60HtDesYbKLTr30yqagQwTrOQZxQtOaEkaPdfRdS2XGZRgN1F8XKK", - "o+RK2p+QhOcXyvu3Wb760kcFkHveivThIR1hTiGdAZKQwBTKFTNsz53KG+dUsBCoS1TNfUrF23SbTUVK", - "EzHGQDod/fVX0asXm+Mg2vzhZfA9fPk8gPDVRrD+w8tXcOOHjVcbqN/x4fllUHGd8b+XDcihf0azQF3s", - "kUJMVZo6UeXFJY6eRHpHTV9iw3rgHZoxIGGOJOG2zrdCMpZmA5FzTBMi87ZbnfwWIVnzQzgEHR1Nd4qW", - "3zvsRolT54t9Omvhq3XbZkQtOs9zioLkCDkgj8hwid5GWCbN9V4Jw3JLiyephhgqAOF3BoA8laGqfpni", - "UHz6TDb1DIziJPwMVtQX4DsFWv5O13xmqzpzad6W26iIyRy9TNNDVUBFCME5sjjsMiVrslXBJviMJBRF", - "PbDNQYwg4xK/Ka8cM4BXc9uXb2NUktEa7bgv3740JVvbR3J5C+rDaij30/HxgR4cWNHzL0bx2oxQbSo7", - "88YQX3Ur0ZZ20yV8Xk5TMTXzVVqPS5DGMESTJJYo/gV6LKTGR0nyma19xdFlp4wV7317xYRpBamrdmH1", - "8YKce1eSc0QpjpC8ggJGkdw13z4YlGCxq9fPsF4tKXrZJJo/SXnYN+znr0ddYhGnbPpKCBkKMGGIMCxE", - "pbgwhSLQ1aT23/7PN/81zPr9jZfPvv1uOAx6//Pbp//8b02KO9+0N7saexcw5JUtDU2eZJdyEGG+OERn", - "WQzpni0Hv8i2sO5AGQWeqJ6KGADS+v5c1Uej9rSL48WpqO6F8Qkp5ohiqDeScxbtgb0LLhZIuC1SCmUN", - "eeW/sS4Ik+QzRqwLEA97FdWkNWbtPCjVTRnY/rCr72vXda/5RK+CIGiPnCczfZpGG8yELIzzdtnVe/2B", - "0YcLacFcebUDm0M+0SSUi76rBnV7zatqSa1VwA7jtil3roucm6rnhWBZfV/hcM+QKkqgrdwd2PWWZ300", - "NJxZjyPXmJIJPFIpWjhQ9+8XiDfP2wroQjbneoaktP4tpLmu9r+Fee0YlJf33jpzfYW+wECeq3KhYzla", - "zOx1q/4aArR8+LJgwPKvGiiNffELBxbHGi1wCYGPulZXEdTK7RZ4u3fcBUJau+Dg5LgLlKx2gRTVLtAi", - "2gVCZKUP+605T7egzD9dcbD8Kw7uTELdQEza9p6Jrn8V4Yg6X8RRdAr+9ncgluhqeCZPf2Hiz+FchU+2", - "ba7AYQsL51HIxZUxRSiQ0dFnNFtTrpRNzqz6uKB2J/Xn4tkjw28fhbc+zeGTBiyJ7WFBs+t43u8q1OSP", - "WRxbw1WsLNSVNYF6/VV1/TXP+VyEhuaiZop+d6C1TQBMhQT8PaG2G43HZJicxSi3oy4q0wFr6n1Ucz05", - "nsIz5AVtXlt1+iTksBCHlE8Iy3zFmq5q4NuA74F9mMooSTmF0l5vh/Yi2CTjTN2RpguAQW6vZlUx1YqK", - "ydRx7DjWB5BXxWKsVdyN6icKUWleeKZLMKw6aCbIwSjhE/Ut6xZb1IGdDgDgZySTByGKxIzoRjLCEO+6", - "i/SMmWOOxamxWGtB4MyXHMBRjI7V255zC4gG2qtWaAjxtm3cCU7lvjtmHBFEve/aeiVmNoadPht2QIQl", - "3kLD1tXLRcxzn5W9pei7FX2ub/UfK1P2H/af6X8mq/64rm5k+/ACT7Op7NIqEKEEKdJTuKL1pLx2wKBB", - "zPbyIgNYf3H1EVz6BcTdMfccnKzZMHduAVMegZuoK2EJ8+1d3+XB8jbl/ASA3QP5Apm5TlGfXF05Od7x", - "3BRZ3Qlud1Wku6e8KGExZDw/E7KiK3mol3MQ3xKJbXfFKmQMnzkYbo1qWkF/ZDCWKQC3+uLqVXKqdjP9", - "a1uEJkVpQnmZqOUBIJ2d/Csto7lNdansVSNsfPtgsBCeRXzwBJDJ4RJySlLsh0z4WdgPmnDfXfsmj7+K", - "+IlD/dZ70aJYO6fggFvD3WYuTK11GZ879diFAVQBUsMbniZUiF9s56TNWzYC8714WjhYaeePIR6YLJrr", - "U9Mc8GKTbPlXF4E8+gZTnAZ6IYN8Pk39duWWqstsqHMnqbdBd7cpbyIS3kySyl8vTy8vyztNJYDKFGJS", - "BKro+vCsN8K/Ywp7ETpfY5Ij2VqFd4SawiFas+iV24Iw1SniK4OYSmpkKbClJzl8ksN7IocLActEaHZf", - "IWWCttI+kBGzQo+57N0aqGz7YNAWT+YAyTS0rBZPVrobtymZWZvDLFxK3T4j2S756NsoPnBKZxZz8tdN", - "9vmm6AiFFPGmo1qLnjFkssUC5QcJ42cUHf3rPZBIe7F8I1VAjbEvCY3KR4E2nl/zIJIi4tYLbe2agR14", - "B7akals1uz1qKXU2ZkWfiEUkpLOUlwllWbpJ2WZIN/nf3IijfkH6c85uN6P/a7eDXP4TxneZPNgFeOyG", - "qZiEcRbJU99P7HlT7LlgsXd3/W8C/n5ktJHHjTTrHNh1dqxVSSm3YJGiR+mba+PgFKRvMf9CC/l9dTE0", - "eTYJUiojo1ej0LldoVtzNio2b1n4dS8zKxd4RwZwJzKe8rDXx6PjtYOTY7CmNAOzqY8e+CS660nW+WQ2", - "XUwthdeAIQTqZUjVEigUZDCZ4lESYcRKWyWPQczmxM3rQf/F8Xp/a9OcPpUxcZVGX/Bb+nae5C4ijLXy", - "VRWdO5ETa5sL0zv/a5sRVFGWSQxeQeBsvwtK3iHiFKNzX2mKt3u5xMmI2Yqd9hUwOQMR0h5UQRIfoeDU", - "2acneboxu3OPZUkI/ICj6V27YdfT9v4MaDvurKQ6n/y0u/PT/PbntnalPur9V0xUvSaZEJI3s51DOnvt", - "xJw6/BZ+GnJizghMEEX+bazleZ5ikg6dnGu55k5GfHuYCYexji9F/KztoWvdXvjOIZr3as8N6Bd64MeE", - "in9kFPOZQoLkhlRfAYCZua1CuqzqbkExy7ZQgrybjGpbDqDBB+lZH80AlhX5kpE8Y6QuETCGW12Y1xZj", - "XdJ/vlIolgHdjIq8jLndTm2TPq/M50Bhqwq1POSmM+uBD4lCBEl0VJHPVQ1AsEIS8Elu7XwCCR2ST/k+", - "0adVH8imAKco71VXrP3V0QVHcIoAZEXIAFgzK6qOaRXSFz613bxbvxTy25XUPMpGdnQq2HPyGBW7MahJ", - "zztYixUH6DDYBQnVU1JM6YSvxhujlxAF6xubz4MXL7//IXgFR2EQoXFf/CR+8d7QkqaxNkteWvLHBZpk", - "yapddH6QUA7jtaPjI/fmHYlNyqHTgDlz4jsB2u2MsMSF7ugbL32kvMEaOqrfKdBjhMIUDYTxTGLtOYXh", - "Z0zOVpt6dZesqWd3GEvonTlybk4SbO8cD37ecyyw/WHwwf71cO/nj+/2dr0+q0vjQQy943HHC9IYEnBy", - "MthV1XEgFzp2irnUNSNs4boOWrEzp195qZbv3DD8I0PFWZRcInuWXE/O9cV8CskmRO21qekIGZhANpH5", - "0HISe6RuJwzgKFzf2LyY/TlXepXs+eieJ9QtjavHULpS0PqsgNu17bbVJV5HJVaYo430Wos3iypz5+P+", - "/t7hzmD7vW/h0UWK6ewYl49OSEW7vhFsrh9vbG69eLX14lV7OyGY8kPlNMbbJI6WKEgFr9Y+9rSepB/J", - "v7KEw0MEzcEz3Y/Ce9tm1D89ZSwnNOE8Ru+FZO0YFrGfrff7fW+JB/ezE4K5G7juY2Gzf0oy2ul2duGs", - "0+3sJ0SdssrHpZ/P2R80033ago2Wwv+ioavJgPjyenJQT3xJBCqsUHCJ2nFyUTzafaPDO6W6a3yoRpFp", - "kJBGcWjF+225uyU7NztuV4VAltdcJdzb6r6lrOJDXZA2+mXBFaiXOOsCz3dMl+wz3pw/6Gv5CprjSlqg", - "DV/dlAO5dLdwxZYKl3vgtqb4a3kV1YFOfAUSzpTkOQFV/B0zXl4jtjo3UFyGvpmja667RL7uTxwYXE3J", - "dlOEtFhUeEWnT/RVBSICMJVAxWQlBOm8WrFqU9w5vex+LV1/O+6cXp5WDssnwluQNdWLDhrMeFI5Mq1P", - "hjEwSb7IfMZPCeO6RAnATEe++vyDruRpDorl90t8Em1/AhGKkRAipsqAUkmF/kCes+qCLxMcTvQTfRzG", - "7TFjlWsswzhjHFHZZA98mkKSwfhTfqJGdD2FHIdOfyKSUoWXmPgzxiEuHwAbuslgPTWqba+QSl+pWv9A", - "r5w8BAZSimTZJ+fqDac0q7fIV+wB1WCKQm655+TwvZQ1dWBL16qW1OYupy7Vl9IkCvR3Wy/6/f4aTPHa", - "+YYbBKj6XwswuP8SBPgXvxrhKEvTeGZqBUEQY44oNAfy5KkpZhL9hkf0Na/gk3ryCXBzYTyyR3ZXX5s2", - "v0BFUTVLS5NpmU67eaqxBff86oYmfnMkxiNv5brLRd3qr7wsVjtOYARGMIYkVKUh5H25rJKCHUGGDrzg", - "0PzaWVVazN4+i0iUJpiI+dUX8ljq9LldLYarPbAdx4V7fIuvyzO8E3iO9LF13VmKSIQiXf3Yudr22doz", - "OTZb5guRyD55Lddc119OSmcNc73goMzWCjCz3m//+7dvdGGcldVvv+u+/vvW//Vf8pLbtdNvrl9szx13", - "5CpJp9jyzF6THawv/aJs58xnmxrb5vCrUyq8YQPGKG9l5Mslwb8gfDbR140UGbP+vhGvqXjj2IgVaXLV", - "lQWUS/+tq1goTKaIKbVh2Ht1nvkI1qUBmWs5uh01GJ+sxqpwmXrBM1hz7+w0izlOXanW09YDh+59C+OM", - "ZxSp1wPtPhVbfK3OxetiWTPEwYqqmCX1KGXceF2YgTCjFBEez2Sh8uINSj/0JbfhqbBVhtfUvzxZo0qF", - "zdib1ZliMlBru+7JoXiOv+d8dtqgL2tPZR/7zr3LiXTOKStVVN2JSggR/VQa3VEPnGPyILKerdJ2w84L", - "NuzIP/v9KRt2isy25GPOP8MYR7L/PUoTz8Vx0n5WB/KjNKvSOI4hjpUZ1C0VU90pCnvmVJN3C54xeDYf", - "eIwEecC87fawo69bqVxdLqU5lOU0c92+1mZe1Cax3PaV5U2sccOh8eekgpNZDPFr3qhQBupwGSbjxBzp", - "gooZNG7kl6OPG9LvMBEhOFYXN5R1wN7RsXxPcJ10WXSFytJtCGarudquLmChnQ9VHbXjqWqxX/CHCpUq", - "9amorroWLcWdrc5mr9/b7Dg1hNZCwTASLaKm6gx5VZrZA49jneAAx++PgPuxo1eEbsrrZDgvKcerNyTH", - "8v7+wueQOjcnnCOqa5z+dHx8cFRwe7QYahypPTI3iLQZ2nFHlB8Ik6Pb6PftWT2VqXJyP2u/M+V7MVvv", - "tslAOv0U8tSShfzWsTDZl12hJ5ZGjtQCTUQMiNA8MDZHE6RcKonJplNIZ4ZQZ5HD4lxyeMaEnnaG7jCg", - "0NYXgZQq4TcHImCQr8NoKhN8+pAdoiKk76Te6zROUmnZICDoS5nHwMrB3j5QdnnVxOJGUGTZF/dlzAwj", - "RjMCp/p6cKFKhPKmSCocE3SbViocpehxBtzpmjOLb5Jo1mL5HCSbQ15nqxOI/97svR18ADt7h8eDHwc7", - "28d78tch2R8Mdv/7eGdn+/MvZ9tfBm+2zwb/3H73vn/y9rvp4Tv++/52/+3O0R9vjwajzd1/7b3Z+XKy", - "vb93crHz5/Y/35x9+HlIer3ekMjW9j7senow1bClv6nWOwgV2GlR/leTZM/0F826DI8qcrh+E3LYxP4u", - "z2ap5gyNoBlncSxRNs9vVyCl5S0wrXY676NuKEhmWBCIa+kFedq2YIfWKBJdSZfGqyT2ZbpK3suJz86Q", - "KtUiqUvGSn25lkUGAMoTjhGbMQXxKqmPiuAfopLgX9uYlA+CWvfJ8YhcutWQdJmro90jW9WjwLWNm9Qt", - "sGLdDk84jN/MuK8gr0LqyZsBzNxqokqmwfa0sbH+4tUrb7BQ9tWaZNQZfllI751kWHbUTLhMq+mRDnnO", - "Xq5UjPxVa8TvABYVixGCor2cQHImTaWJHa9jK1XHRVvp3FOw9WsFZrhrTk+6pPIE6KEVwqcXffTD834/", - "QBuvRsHz9eh5AL9ffxk8f/7y5YsXz5/3VdSOiTxSKg8ha/OGo07ZHrk2rhxTnC5VzNXm2cLDaIq2vOpC", - "T9kNK4sFhdgSVbWzz29PhF2CREQ5TjIS3UtF4pPc5SiQOJ7aG9wDk36vD/hkHPD+/X5+ebn9BlB0hhlH", - "NI/wtELo2kRfPBO2Vr0zUhdy9ryxmroUXvZwbImaozR+lC3LfQuzjVB78+fHFJHtgVELsvJ2rheK58Vv", - "SyGEFUjWphfqvqANd5e0FdDJM/VtME71wa2fXe53mFtDcy5y4gUzTcDM0yLCVxfnbkfGlfbSUIlutx1u", - "l1utTCMXDPBX7YhI/D26ED/K1KRBupu69G5nVZFUsE0fZywa9LZbTE9PeRBZwOWszeA0XlLDtxqdesXM", - "I0ReJjBVIu9HmFpEc+RZY51HXlWUvbpFw56QcYxDDoJcNGWqmMGpviMKxhTBaKZAOvdTGSmha1IGy9RH", - "9c5A67iC1KisSohREyD49UujzdebqWk2inHo7qmaO8ActemJHWQCHD+A6MAS2s7/96+D1+m+Dc9/AXJu", - "Owbwk/YwogFy81qh6w8D3iJeL+6jGcCcgcFuVc7fIp9n/2Y2iK4s6KYGdd1U3EthX9wxWLLTs4iUcohj", - "9iSYLQRTiEW9TERLDh8y7y6ZhvPl6GM/QcUI3be9FcEbt8gqFXWjQvoXik369yM28eYX73ls8qTX5uzw", - "tdMqNxmPLJCTvGoqsmuKonSBhjd1gfKF5XVQ5xJvPy9duUCasjCHc1KVdjKvmbPstiTHKQ/j3F3T69d0", - "n79+/a713K9p5e+AdIvGoURCjki7Egmlqxyy4h10zl0N/t7tN3nnc698uPLaCEYskAdT3FOT0wuTad0a", - "6c/uLqG94UtoFwR80Qx1ocraDZRHaZfVfkDJ7Noc9pLhWnVp7Er2OleAOnstj10lQHAIhaFGfupgU9dH", - "7zol8SwC0J466ALnYjapzaFcb3P5W1djwlUt+hbJ7ptPcntrzi7Nm6xpvdk1wQT839v774Xhk5cCagDS", - "HaXIS3I+h3aTHpcViM3FRU+58nm5cqsLyrlyEtnb7h5y3vzaqs/jlV41OX6FnHjLyLsacpfmIDeE8g4N", - "5TcEacm/vMfJ8Bqyr5Aavx8Z8fuXCH+I+e8lSPcC2e7WSe4FktuPQXKvaM9vwtNpIXf3ILX9wDLaMpHt", - "VhlZbixxlZz2wqnshyaOf4HQ40QnjUszfCcp78WUyP1Ndz/ptStntG8sUljTVT7mZLNhHMtTn+JNH0Jv", - "rs4rJaW3DwbvRKftFJ+qcONTeoUaR4a4h++YqOlpe1jTLMyTfDX7DaZUtjtnPmZeghcRJoRl08aE5FtE", - "EM3zApqgKwlXJUGo+Gcp0nVmyBQPNYEP2tVQcyOnbGkORl2btwrgLRNRLzCG1x4iavdeqLe7SYiuRJnq", - "RAliIncm5SO17yA8iNX7nwFt0HTL1bxzPJ61rzDF75Dcom7MmB6i8+Sz9M006T3wkYQIUPl71AWYgxAS", - "QBIQJ+RMBKW6QgRP3K0fe78Y8x3iFW0tX4XfjqqubBSLOTXkmPWWrpoYZYEmC+/Oy8156MlX6p75aGLd", - "wtYKV3PMk8JtoXB1+Xsxbffbtayoh1vxKZsTU4YStVdtiqTo63cI40hXIMh4EmgPT9iQhKAW6apHqZo8", - "0M+bV0035d0Wq+0uw7ctt3ir8M/FPdt7lQQzt4o+GBX75N5eNXl3L33bNYpMFF9fqebQvlNIQl4nLZE3", - "+fj9WjvBj8OA2KVbcorE3+49NyY04U9pksfntVt9dxdK+wK3LGoiXryj4wOSxkUPD1zMQBH6f3cHBy5m", - "d3Nq4GJ2L48M3IsDA2JNHttpASPLC5wVuJjd+UEBSfVDOCag1VBJD1/MbvyEwMXMfzxAqLj2ZwNywHdZ", - "dednBornAxY4DnAxu9GzACU2XSYap7bpOv/iYnZ/jgBUxLeJ6ifw/1XB/xezR4j8lyK7NGVWcikXR/9f", - "zBaE/l/MrgtXlC2UT9gH5sHDqHxjyV0I5C8tx90i/OtIuKOo8WL20LD9y5XfVgj/i1kreP/FbBnY/vsu", - "nVexzkt3V+YJ2J3i+O+9TDkgfsXaWZknl+zvL4biV55mawj/AzGIjzpGKMH1bVh0m1j9hVTEE0r/wWmt", - "JoVx0y799WH6LZSak/mdLQGgfzGbj85/UN7Fw0LlPwgvoAUk//rCtSwwfgsRKubmrr/XrWRoLgb/oXgM", - "T9j7J+z9tZTYEzJp6cD7perXRt/l3gLul6Opb1YjXw9ifzF7wtc/KdVcqT4acP2yvcO7gdU/JgXkB9Lf", - "pAJ6QtE/oejvmyJ9clSXC6G/Iy91+dD5FkmEMm7+cbmndUj5h2ghnmDyTzD5R+18z8HIL10rT8O0HTp+", - "f+fgYOng+IRq3LR/byTvsz0qfn/noIiKr9bT31dvHbi6ePmY+JyQ28XE5/3WY+LROaIzPhFtPU5c/E0j", - "01/4kOnTMD1YEJyuOfwOwemOjN1rbHpBFxgNaMX45qDpZoXKyPSanSjz+g2hxL38shxHaE7Tt7q7UyMW", - "VRayq/N0H2pbmHcuM48I6u2I3dJ0Q8k9WgDpbbmyLdDbIf9aV6vlY7a3nfaGRccjN/2BGJzrh9xjDLif", - "6nZQcLsad4YEb6bgtuMiS83DwIHfiGw3o8DtDDWDwM1r17q9tCy5D0Ver2K+l+6ezBG2uwGFPxD5Erxe", - "YPRoyY51Swy4paEdBPxGTKVK1N+q6P3FYoP+HcYGT/eRPgZ91aA6lu31U8R4AFM8JyV6iBjfPhjcYkLU", - "9Ng+Hbp9MKhPhB4iKE/Dy9FsHwxuLhkqyLjdNKjosT4BStXIgxjLEheP8zbR5YZkRh5a5TU1o/oymS2T", - "qTeW8LQydK/TnY6kG9UmfpJsfWO5Tt1py1SnWeOb8WZ068vxXyqN3Wo20wpDlSfMjD+lL9umL8VsPaLE", - "ZS5EyxLzggPTOmlpZb9tyjIn/FphmFY3/lyla6UlVuWBZCvr6G6XrzQrcWfpykYCbjs6McQ8kGTl8uW5", - "KVVppbY5UanfulaecpxQI7APR0zbWeUleBbNYnQ3eciHITmCj10ujpbr8bZMQhoK2uUgl2v7/MnHGxaq", - "R+iw92/TYX/KKT4C3VOvCG7UH79ybYnWakp8v1hBiXlKylaV0CfiJUWPwg94IEUmHo41byoxcX3RumZt", - "iToRAse60gNmAILNjWA04whQSCJ73hCRMIlUin+CLmCEQjyFcRekFI3xBYpUWuITTHH626ceOGHICtA7", - "NFP1ZWcgIa5YaVWNACZhMhUKyBygVq3xCWbyPHZNDm6hcyrzZNxX9eKheyVPBTCeCmA8JgXbVF9iqcq1", - "wW25h2UllqoHFXl3ogUXKzoxj6yn6hNPGu3ea7SKkliqg3jb5SWWpojuncpRGY87UTlP9Sae6k3cruoU", - "E/RgTg3X6jPhI+bn/yOl2G7fRVxaTYfG4D2l6BwnGTNRvHEOIBGslcYwNCG6mpglxPgNhSQeT2C+eKGJ", - "R2UjnipOPFWceGwOd12RiaUnEBgKKeL1+xyHZlcB2owxjGPAeEIFl6mve+AQ8YwSpn9w9KTKkiYZHxKh", - "jWDIMzl2+ZrU6CrzzFCYUcxnIM1omjDE1G5rddPkSBN8g1Knumi736DnwO6/+GRv/fb464SIdU8o/hNF", - "IChfo2ZV172G1jK7xobT9aq3Z/T6vYcjwbpMuxiaEREJ6SyVN5JxIBwm5bDop4NdMM0Yl6kv6Q70hkQ8", - "1lEocz7PmHCJuHR2sBiWeSYm394IO0LjhCKQIsow44iEyMftKpGoRn5DEF7V+A0cR2pseElZeO2/qPof", - "KnMuCbT8dGTlUGXW1VkF5WIruPzP+gTDVudMO6rC+0ljyMcJnfa+sGSjFybTtfP1TrfzGROxLHZBpojD", - "CHI5F+YcBuRwBBkKUsjYl4RKOWMpCqtseJAwfkbR0b/egynEBJhPgf20WzjWsdXZNW8cuI1baKGegm3e", - "2eps9DdeBv31oP/ieL2/tdnf6vf/LRy6yEtjt6OjzPpvL+WqXWPt1eoqllbRkE9LqE/vxz7IG5gHvAGY", - "YiZFO6EAa+9mjFEcsXus4O8KAK7VZr49Oti9l6hvELjaWbmkTZs5zEj+NayS43PNRX4fIDqFYqCxqUsg", - "zJaeXYsCN/IsTBZmand8AmmkP5HLMCREhH9hco7oDExROIEEs6myctbqiG9xhKZpIlYEBKoFeRkrIAkJ", - "5NohwodE00C11/e8/9xnwBTk1jFgVX/NK/4+VDNYIQnQvLJ6r2Xu+YKmiyQ8UKFI0XjpuUgQk9GKnHzX", - "fFlkekevRjHayiOc3EiIvn7TYU97fT53do6a+78vsm4trJD0jKI6gPgyxLzbHE0xffOtVD65UBe8Tutd", - "6tdc73JIfG5lOBGOhHYuR0hhVYSEoqgHBipwMy8zOQuAJ0Oi25fKRPXdBRC86Pf1zMlMnWrGZOdkeIpD", - "oHnQJ/xvEW+U/AUkxByVqHPudOQF48fl3dnBdFiWblK2GdJN/reH5/QZpo8adEcePDuC8XBC6VvNYT0U", - "dYuaXSsns7Qcjdsmj1/JT+V5cF1HUvz1oqhqhISyVO5ODHYdsUxpEvWiUU9IeK+gE7BKrBf0lfyt2IBH", - "oVwuCanXsK3OCts3rrOu3FxJnTJF9p+FLMeQ5GmOMKNUOIsN6Y4uQASOYn2pfzKFXFgOfKY4d0h4IvpB", - "VMFQo4zmhdlZD3yMIyfFJpWpiCTgKEbgHEOda3EtoM8aqZH/NXMpi5pbbRdqza29zeIpk9LeqK5vPX9x", - "B5mUewEfmJtJUYz0ZN4fknmflzkxkIflZU2ykaVLKBbS4nCO+w2Q3wB4DnEsrUebIzpHTgMHss+b3Hcq", - "ddZ6B6oyyvu7veOh9Tr7mfXbPDZzV+kR8AnkIEJjTBADcpc1xlPMVVAOpaIEXO5djjXCyG2D1Z30KC/f", - "TfkZpW5MqZc7OeNQJqZRsVUWwuza3KFBurM8+f0+u1ARmmtKqV+Br30Vfwxa1j+pCnLbSigeySwFi56Y", - "S5F2TfT9c0+SuzIMne++dU/jw8Mo2LFsXmwo1yH3U1QxCIl08fBccx2Pu+O0/j3R6XdVS+PDvT91W8NN", - "MiN0TQ+oZQ2Nav/tqmncKlffvMdUOQJweW+lyeRinqTJH1vesJsyJ8QsvNq2sOz2waALnAmcW1L2qEDQ", - "QnVlB7tgxSlzOtgVfanLEFdryprCFEupbYSb+z+0Q7paAw0FVbd3jgc/73W6ncEH+9fDvZ8/vtvbvYmy", - "qm3l+SoB+gOJzW8qLNfTN5KGyRm0PE/cunpKNeC+hWD73gTarU3IXzm+BkHROjyksqOsyNhLtWhrX91/", - "Xin2vkrY3cplLFJ2w6H3XUXdBSLIwwvB7yr6bh943z6v9e9Wz99VzP2AWNkTgN9h7L142H0rPH2z/tOd", - "hd2tWfiuou0HJEfe0Pu6PoroQZ//k6wt393O+KSz9eupYE1FkC/efZ+EMAa6mqPsrdvJaNzZ6kw4T7fW", - "1mLxwiRhfOtV/1V/DaZ4bWpJWztf71SPT+8m4WdE195lI0SJRN3nMXS5eY12CcQK0SSOEa3t59TOUmWv", - "8vBkN4fhq21HM5EsF2/f3Fap9zVWuJpXt+a9h6fanHpoCq8cvz8CIaIcj2XVJ9X6T8fHB0cgSxmnCE7B", - "OaLqseIM3d1O/tXi9Ot71BXI6xhN01g0U4BIOCPzv329Tlv1ddUu1E3gTe3PWyVf4/lJWd2WB3hxeXr5", - "/wcAAP//nIc3NSrXAQA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index 3b986c23bc..239786a5c4 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -27,6 +27,7 @@ import ( api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/upstreamref" ) // APIValidator validates API configurations using rule-based validation @@ -37,6 +38,9 @@ type APIValidator struct { versionRegex *regexp.Regexp // urlFriendlyNameRegex matches URL-safe characters for API names urlFriendlyNameRegex *regexp.Regexp + // upstreamRefRegex enforces the schema pattern shared by upstream refs + // (API-level and per-op) and upstream definition names + upstreamRefRegex *regexp.Regexp // policyValidator validates policy references and parameters policyValidator *PolicyValidator } @@ -47,6 +51,7 @@ func NewAPIValidator() *APIValidator { pathParamRegex: regexp.MustCompile(`\{[a-zA-Z0-9_]+\}`), versionRegex: regexp.MustCompile(`^v?\d+(\.\d+)?(\.\d+)?$`), urlFriendlyNameRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_\. ]+$`), + upstreamRefRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`), } } @@ -178,6 +183,36 @@ func (v *APIValidator) validateUpstreamUrl(label string, upUrl *string) []Valida return errors } +// validateUpstreamRefName enforces the shared UpstreamReference name contract +// (max 100 characters, ^[a-zA-Z0-9\-_]+$) on definition names and refs. The +// message names the field from the trailing segment of its path. +func (v *APIValidator) validateUpstreamRefName(field, value string) []ValidationError { + name := fieldName(field) + if len(value) > 100 { + return []ValidationError{{ + Field: field, + Message: fmt.Sprintf("%s must not exceed %d characters", name, 100), + }} + } + if !v.upstreamRefRegex.MatchString(value) { + return []ValidationError{{ + Field: field, + Message: name + " must match pattern " + v.upstreamRefRegex.String(), + }} + } + return nil +} + +// fieldName returns the trailing path segment of a validation field path +// (for example "spec.operations[2].upstream.main.ref" yields "ref") so error +// messages can name the field without a caller-supplied label. +func fieldName(field string) string { + if i := strings.LastIndex(field, "."); i >= 0 { + return field[i+1:] + } + return field +} + func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { var errors []ValidationError @@ -192,7 +227,10 @@ func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDe refName := strings.TrimSpace(*ref) - // Check if upstream definitions are provided + if errs := v.validateUpstreamRefName("spec.upstream."+label+".ref", refName); errs != nil { + return errs + } + if upstreamDefinitions == nil || len(*upstreamDefinitions) == 0 { errors = append(errors, ValidationError{ Field: "spec.upstream." + label + ".ref", @@ -201,16 +239,8 @@ func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDe return errors } - // Check if the referenced definition exists - found := false - for _, def := range *upstreamDefinitions { - if def.Name == refName { - found = true - break - } - } - - if !found { + // Resolve via the shared upstreamref helper so API-level, per-op, and translator lookups match. + if _, err := upstreamref.FindByName(refName, upstreamDefinitions); err != nil { errors = append(errors, ValidationError{ Field: "spec.upstream." + label + ".ref", Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", refName), @@ -466,7 +496,7 @@ func (v *APIValidator) validateRestData(spec *api.APIConfigData) []ValidationErr errors = append(errors, v.validateResilience("spec.resilience", spec.Resilience)...) // Validate operations - errors = append(errors, v.validateOperations(spec.Context, spec.Operations)...) + errors = append(errors, v.validateOperations(spec.Context, spec.Operations, spec.UpstreamDefinitions)...) return errors } @@ -565,7 +595,7 @@ func (v *APIValidator) ValidateContext(context string) []ValidationError { // gateway-controller/pkg/xds/translator.go) — that namespace is reserved for the // gateway's own /ready and /healthy direct-response routes, and must never be // reachable by anything an API defines. -func (v *APIValidator) validateOperations(context string, operations []api.Operation) []ValidationError { +func (v *APIValidator) validateOperations(context string, operations []api.Operation, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { var errors []ValidationError if len(operations) == 0 { @@ -637,6 +667,9 @@ func (v *APIValidator) validateOperations(context string, operations []api.Opera // Validate operation-level resilience block errors = append(errors, v.validateResilience(fmt.Sprintf("spec.operations[%d].resilience", i), op.Resilience)...) + + // Validate per-operation upstream override (main / sandbox) + errors = append(errors, v.validateOperationUpstream(i, op.Upstream, upstreamDefinitions)...) } return errors @@ -651,6 +684,59 @@ func joinContextPath(context, opPath string) string { return strings.TrimSuffix(context, "/") + "/" + strings.TrimPrefix(opPath, "/") } +// validateOperationUpstream validates the ref-only per-operation main/sandbox +// overrides; each present ref must name an entry in upstreamDefinitions. +func (v *APIValidator) validateOperationUpstream(opIdx int, up *api.OperationUpstream, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { + var errors []ValidationError + if up == nil { + return errors + } + if up.Main == nil && up.Sandbox == nil { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("spec.operations[%d].upstream", opIdx), + Message: "At least one of 'main' or 'sandbox' must be set", + }) + return errors + } + if up.Main != nil { + errs := v.validateOperationUpstreamRef(opIdx, "main", up.Main.Ref, upstreamDefinitions) + errors = append(errors, errs...) + } + if up.Sandbox != nil { + errs := v.validateOperationUpstreamRef(opIdx, "sandbox", up.Sandbox.Ref, upstreamDefinitions) + errors = append(errors, errs...) + } + return errors +} + +// validateOperationUpstreamRef validates a single operation-level upstream ref. +// The ref must resolve to a named entry in upstreamDefinitions. +func (v *APIValidator) validateOperationUpstreamRef(opIdx int, env, ref string, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError { + field := fmt.Sprintf("spec.operations[%d].upstream.%s.ref", opIdx, env) + + refName := strings.TrimSpace(ref) + if refName == "" { + return []ValidationError{{ + Field: field, + Message: "Upstream ref is required", + }} + } + + if errs := v.validateUpstreamRefName(field, refName); errs != nil { + return errs + } + + // Resolve via the shared upstreamref helper (same lookup as the translators). + if _, err := upstreamref.FindByName(refName, upstreamDefinitions); err != nil { + return []ValidationError{{ + Field: field, + Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", refName), + }} + } + + return nil +} + // validatePathParameters checks if path parameters have balanced braces func (v *APIValidator) validatePathParameters(path string) bool { openCount := strings.Count(path, "{") diff --git a/gateway/gateway-controller/pkg/config/validator_test.go b/gateway/gateway-controller/pkg/config/validator_test.go index 05b8ffd5c2..3c544fad81 100644 --- a/gateway/gateway-controller/pkg/config/validator_test.go +++ b/gateway/gateway-controller/pkg/config/validator_test.go @@ -911,6 +911,106 @@ func TestValidateUpstreamDefinitions_NonPositiveConnectTimeout(t *testing.T) { } } +func TestValidateUpstreamDefinitions_MalformedTimeout(t *testing.T) { + validator := NewAPIValidator() + + connect := "abc" + definitions := &[]api.UpstreamDefinition{ + { + Name: "my-upstream", + Timeout: &api.UpstreamTimeout{ + Connect: &connect, + }, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + { + Url: "http://backend:8080", + }, + }, + }, + } + + errors := validator.validateUpstreamDefinitions(definitions) + require.Len(t, errors, 1) + assert.Equal(t, "spec.upstreamDefinitions[0].timeout.connect", errors[0].Field) + assert.Contains(t, errors[0].Message, "Invalid timeout format") +} + +func TestValidateUpstreamDefinitions_TimeoutUnitContract(t *testing.T) { + validator := NewAPIValidator() + + // time.ParseDuration accepts units outside the ms|s|m|h contract (ns, us), compound + // durations, and leading signs that the published schema does not allow; these must + // be rejected as invalid format, not silently accepted. + for _, badTimeout := range []string{"5ns", "100us", "1h30m", "+5s", "-5s"} { + connect := badTimeout + definitions := &[]api.UpstreamDefinition{ + { + Name: "my-upstream", + Timeout: &api.UpstreamTimeout{ + Connect: &connect, + }, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://backend:8080"}, + }, + }, + } + + errors := validator.validateUpstreamDefinitions(definitions) + require.Len(t, errors, 1, "timeout %q must be rejected", badTimeout) + assert.Equal(t, "spec.upstreamDefinitions[0].timeout.connect", errors[0].Field) + assert.Contains(t, errors[0].Message, "Invalid timeout format") + } +} + +// TestValidateUpstreamDefinitions_NameRules covers the definition-name contract +// (max 100 chars, pattern ^[a-zA-Z0-9\-_]+$) so a valid name stays referenceable +// from a per-op upstream override. +func TestValidateUpstreamDefinitions_NameRules(t *testing.T) { + validator := NewAPIValidator() + + validUpstreams := []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://backend:8080"}, + } + + tests := []struct { + name string + defName string + wantMsg string // empty means the name is accepted + }{ + {"over-length is rejected", strings.Repeat("a", 101), "must be 1-100 characters"}, + {"space is rejected", "bad name", "letters, numbers, hyphens, underscores"}, + {"dot is rejected", "has.dot", "letters, numbers, hyphens, underscores"}, + {"colon is rejected", "has:colon", "letters, numbers, hyphens, underscores"}, + {"slash is rejected", "has/slash", "letters, numbers, hyphens, underscores"}, + {"valid name is accepted", "valid-name_123", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + definitions := &[]api.UpstreamDefinition{ + {Name: tt.defName, Upstreams: validUpstreams}, + } + errors := validator.validateUpstreamDefinitions(definitions) + if tt.wantMsg == "" { + assert.Empty(t, errors) + return + } + require.Len(t, errors, 1) + assert.Equal(t, "spec.upstreamDefinitions[0].name", errors[0].Field) + assert.Contains(t, errors[0].Message, tt.wantMsg) + }) + } +} + func TestValidateUpstreamRef_ValidRef(t *testing.T) { validator := NewAPIValidator() @@ -992,3 +1092,294 @@ func TestValidateUpstream_WithRefAndDefinitions(t *testing.T) { errors := validator.validateUpstream("main", upstream, definitions) assert.Empty(t, errors) } + +// TestValidateOperationUpstream_ValidRef asserts that a well-formed ref passes validation +// when it resolves to a known upstream definition. +func TestValidateOperationUpstream_ValidRef(t *testing.T) { + validator := NewAPIValidator() + definitions := &[]api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + } + up := &api.OperationUpstream{ + Main: opRef("user-svc-cluster"), + } + + errors := validator.validateOperationUpstream(0, up, definitions) + assert.Empty(t, errors) +} + +// TestValidateOperationUpstream_EmptyRef asserts that an empty ref is rejected +// with a per-op-scoped error field path. +func TestValidateOperationUpstream_EmptyRef(t *testing.T) { + validator := NewAPIValidator() + up := &api.OperationUpstream{ + Main: opRef(""), + } + + errors := validator.validateOperationUpstream(2, up, nil) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[2].upstream.main") { + found = true + assert.Contains(t, e.Message, "Upstream ref is required", + "empty ref should be rejected with the required-ref reason") + break + } + } + assert.True(t, found, "validation error should be scoped to spec.operations[2].upstream.main, got %+v", errors) +} + +// TestValidateOperationUpstream_UnknownRef asserts that a ref not matching any +// upstream definition is rejected. +func TestValidateOperationUpstream_UnknownRef(t *testing.T) { + validator := NewAPIValidator() + up := &api.OperationUpstream{ + Main: opRef("missing-cluster"), + } + definitions := &[]api.UpstreamDefinition{ + { + Name: "user-svc-cluster", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://user-svc:8080"}, + }, + }, + } + + errors := validator.validateOperationUpstream(0, up, definitions) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main") { + found = true + assert.Contains(t, e.Message, "not found in upstreamDefinitions", + "unknown ref should be rejected with the not-found reason") + break + } + } + assert.True(t, found, "expected unknown-ref error scoped to main, got %+v", errors) +} + +// TestValidateOperationUpstream_EmptyWrapper asserts that a wrapper with neither +// main nor sandbox set is rejected. +func TestValidateOperationUpstream_EmptyWrapper(t *testing.T) { + validator := NewAPIValidator() + up := &api.OperationUpstream{} + + errors := validator.validateOperationUpstream(3, up, nil) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if e.Field == "spec.operations[3].upstream" && + strings.Contains(strings.ToLower(e.Message), "at least one") { + found = true + break + } + } + assert.True(t, found, "expected 'at least one' error at wrapper level, got %+v", errors) +} + +// TestValidateOperationUpstream_SandboxUnknownRef asserts the sandbox sub-field is +// validated too (the existence check runs for sandbox), with a sandbox-scoped field path. +func TestValidateOperationUpstream_SandboxUnknownRef(t *testing.T) { + validator := NewAPIValidator() + definitions := &[]api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + } + up := &api.OperationUpstream{ + Sandbox: opRef("missing-cluster"), + } + + errors := validator.validateOperationUpstream(0, up, definitions) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.sandbox") { + found = true + assert.Contains(t, e.Message, "not found in upstreamDefinitions", + "unknown sandbox ref should be rejected with the not-found reason") + break + } + } + assert.True(t, found, "expected unknown-ref error scoped to sandbox, got %+v", errors) +} + +// TestValidateOperationUpstream_RefPatternRejected asserts that a ref containing +// characters outside ^[a-zA-Z0-9\-_]+$ is rejected before the existence check. +func TestValidateOperationUpstream_RefPatternRejected(t *testing.T) { + validator := NewAPIValidator() + definitions := &[]api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + } + + for _, badRef := range []string{"bad/ref", "bad ref", "bad.ref!", "../etc"} { + up := &api.OperationUpstream{ + Main: opRef(badRef), + } + errors := validator.validateOperationUpstream(0, up, definitions) + require.NotEmpty(t, errors, "ref %q must be rejected", badRef) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main") && + strings.Contains(e.Message, "must match pattern") { + found = true + break + } + } + assert.True(t, found, "expected pattern-rejection error for ref %q, got %+v", badRef, errors) + } +} + +// TestValidateOperationUpstream_RefMaxLength asserts that a ref longer than 100 +// characters is rejected, matching the OpenAPI schema maxLength constraint. +func TestValidateOperationUpstream_RefMaxLength(t *testing.T) { + validator := NewAPIValidator() + longRef := strings.Repeat("a", 101) + exactRef := strings.Repeat("b", 100) + definitions := &[]api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + {Name: longRef, Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://long-svc:8080"}}}, + {Name: exactRef, Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://exact-svc:8080"}}}, + } + + up := &api.OperationUpstream{ + Main: opRef(longRef), + } + errors := validator.validateOperationUpstream(0, up, definitions) + require.NotEmpty(t, errors, "ref longer than 100 chars must be rejected") + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main") && + strings.Contains(e.Message, "must not exceed 100 characters") { + found = true + break + } + } + assert.True(t, found, "expected maxLength-rejection error for ref of len %d, got %+v", len(longRef), errors) + + // Boundary: exactly 100 characters should pass + up = &api.OperationUpstream{ + Main: opRef(exactRef), + } + errors = validator.validateOperationUpstream(0, up, definitions) + assert.Empty(t, errors, "ref of exactly 100 chars must pass") +} + +// TestValidate_PerOpRef_FullFlow exercises the complete entry path +// Validate -> validateRestData -> validateOperations -> validateOperationUpstream, +// confirming a per-op ref error surfaces from the public Validate API with the +// operation-scoped field path (not just the helper in isolation). +func TestValidate_PerOpRef_FullFlow(t *testing.T) { + validator := NewAPIValidator() + config := &api.RestAPI{ + ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "per-op-ref-api-v1.0"}, + Spec: api.APIConfigData{ + DisplayName: "PerOpRefAPI", + Version: "v1.0", + Context: "/per-op", + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: func() *string { s := "http://example.com"; return &s }()}, + }, + Operations: []api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), + Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("missing-cluster"), + }, + }, + }, + }, + } + + errors := validator.Validate(config) + require.NotEmpty(t, errors) + found := false + for _, e := range errors { + if strings.Contains(e.Field, "spec.operations[0].upstream.main") && + strings.Contains(e.Message, "not found") { + found = true + break + } + } + assert.True(t, found, "expected per-op ref error via full Validate, got %+v", errors) +} + +// TestValidate_APILevelRefPatternAndLength asserts the API-level upstream ref +// shares the name-pattern and length contract enforced for per-op refs. +func TestValidate_APILevelRefPatternAndLength(t *testing.T) { + validator := NewAPIValidator() + + base := func(ref string) *api.RestAPI { + r := ref + return &api.RestAPI{ + ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "api-ref-v1.0"}, + Spec: api.APIConfigData{ + DisplayName: "APIRef", + Version: "v1.0", + Context: "/api-ref", + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: api.Upstream{Ref: &r}}, + Operations: []api.Operation{{Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/x")}}, + }, + } + } + + hasRefErr := func(errs []ValidationError, msgSub string) bool { + for _, e := range errs { + if e.Field == "spec.upstream.main.ref" && strings.Contains(e.Message, msgSub) { + return true + } + } + return false + } + + t.Run("bad pattern is rejected with a pattern error", func(t *testing.T) { + errs := validator.Validate(base("bad/ref")) + assert.True(t, hasRefErr(errs, "must match pattern"), "API-level ref with bad characters should give a pattern error, got %+v", errs) + }) + + t.Run("over-length ref is rejected with a length error", func(t *testing.T) { + errs := validator.Validate(base(strings.Repeat("a", 101))) + assert.True(t, hasRefErr(errs, "must not exceed 100 characters"), "API-level ref over 100 chars should give a length error, got %+v", errs) + }) +} + +// opRef builds the inline per-operation upstream target holding a ref. +func opRef(ref string) *struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` +} { + return &struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` + }{Ref: ref} +} diff --git a/gateway/gateway-controller/pkg/transform/llm_test.go b/gateway/gateway-controller/pkg/transform/llm_test.go new file mode 100644 index 0000000000..81b94a5866 --- /dev/null +++ b/gateway/gateway-controller/pkg/transform/llm_test.go @@ -0,0 +1,63 @@ +/* + * 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. + */ + +package transform + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" +) + +// TestLLMTransformer_ClusterNameKeyedOnLLMUUID verifies that an LLM config receives the +// same identity-based cluster name as a REST API. LLMTransformer converts the config to a +// RestAPI and delegates to RestAPITransformer; this pins that the LLM config's UUID is +// carried through the extra hop, so the cluster name is clusterkey.HashedName(env, UUID) +// and not keyed on anything LLM-specific. +func TestLLMTransformer_ClusterNameKeyedOnLLMUUID(t *testing.T) { + // A RestAPI Configuration is supplied directly, so Transform uses it as-is and skips + // the provider transform; no storage backend is needed for this path. + base := makeRestAPIWithOps([]api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}, + }) + restAPI := base.Configuration.(api.RestAPI) + cfg := &models.StoredConfig{ + UUID: "test-llm-api", + Kind: "LlmProxy", + Configuration: restAPI, + } + + // Construct directly with only restTransformer set: the RestAPI-direct path does not + // use the provider transformer or storage, so this avoids an unrelated db dependency. + transformer := &LLMTransformer{ + restTransformer: NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}), + } + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute, "main route must exist") + assert.Equal(t, clusterkey.HashedName("main", cfg.UUID), mainRoute.Upstream.ClusterKey, + "LLM cluster name must be the identity name keyed on the LLM config UUID") +} diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index 953f053802..a76fd69275 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -32,6 +32,8 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/upstreamref" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" policyv1alpha "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" @@ -131,10 +133,10 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim mainUpstreamInfo := mainUpstream.UpstreamInfo() // Determine vhosts to create routes for. - // Sandbox is active when a sandbox upstream is configured via either url or ref. - hasSandbox := apiData.Upstream.Sandbox != nil && - ((apiData.Upstream.Sandbox.Url != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Url) != "") || - (apiData.Upstream.Sandbox.Ref != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Ref) != "")) + // Sandbox is active when a sandbox upstream is configured via either url or ref, + // or when any operation carries a per-op sandbox ref. + apiSandboxHasContent := upstreamref.HasContent(apiData.Upstream.Sandbox) + hasSandbox := upstreamref.SandboxActive(apiData.Upstream.Sandbox, apiData.Operations) // Check if dynamic cluster selection should be used. Enabled whenever the API has named // upstream definitions (so a policy can select one) OR a sandbox upstream (so a policy can @@ -177,6 +179,16 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim return nil, fmt.Errorf("invalid API-level resilience: %w", err) } + // Per-op sandbox routes carry no HostRewrite; inherit the API-level sandbox + // setting when present, else the main setting (matches the xDS path). + sandboxAutoHostRewrite := mainAutoHostRewrite + if apiSandboxHasContent { + sandboxAutoHostRewrite = true + if apiData.Upstream.Sandbox.HostRewrite != nil && *apiData.Upstream.Sandbox.HostRewrite == api.Manual { + sandboxAutoHostRewrite = false + } + } + // Build routes and policy chains for each operation for i, op := range apiData.Operations { // Operation-level resilience overrides API-level (per field); nil leaves the @@ -187,11 +199,6 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim } routeTimeout := buildRouteTimeout(opTimeout, apiTimeout, opIdleTimeout, apiIdleTimeout) - vhosts := append([]string{}, mainVhosts...) - if hasSandbox { - vhosts = append(vhosts, effectiveSandboxVHost) - } - // Resolve the effective matching criteria (simple top-level form or the richer match // block) once per operation. Header matchers and their discriminator are vhost- // independent; the discriminator keeps the route key unique across operations that @@ -203,28 +210,69 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim headerMatches := routeHeaderMatches(op) discriminator := xds.HeaderMatchDiscriminator(headerMatches) - for _, vhost := range vhosts { + mainSlot := routeSlot{ + clusterKey: mainUpstream.ClusterKey, + useClusterHeader: useClusterHeader, + defaultCluster: defaultCluster, + autoHostRewrite: mainAutoHostRewrite, + defaultUpstream: mainUpstreamInfo, + } + // The sandbox slot starts as the main slot; only autoHostRewrite differs + // until a per-op sandbox ref overrides it below (API-level sandbox routes + // are re-pointed by the sandbox patch after this loop). + sandboxSlot := mainSlot + sandboxSlot.autoHostRewrite = sandboxAutoHostRewrite + + if op.Upstream != nil { + if op.Upstream.Main != nil { + if err := mainSlot.applyPerOpRef("main", cfg.Kind, cfg.UUID, method, opPath, op.Upstream.Main.Ref, apiData.UpstreamDefinitions); err != nil { + return nil, err + } + } + if op.Upstream.Sandbox != nil { + if err := sandboxSlot.applyPerOpRef("sandbox", cfg.Kind, cfg.UUID, method, opPath, op.Upstream.Sandbox.Ref, apiData.UpstreamDefinitions); err != nil { + return nil, err + } + } + } + + vhosts := append([]string{}, mainVhosts...) + // Add the sandbox vhost only when this op has sandbox config (API-level + // fallback or a per-op override); otherwise it would route to the main cluster. + sbIdx := -1 + if apiSandboxHasContent || (op.Upstream != nil && op.Upstream.Sandbox != nil) { + vhosts = append(vhosts, effectiveSandboxVHost) + sbIdx = len(vhosts) - 1 + } + + for vi, vhost := range vhosts { routeKey := xds.GenerateRouteNameWithDiscriminator(method, apiData.Context, apiData.Version, opPath, vhost, discriminator) - // Build route. Default is this route's own upstream (main's, until the sandbox - // patch below overwrites it for sandbox-vhost routes) — the single field exposed - // to the policy engine as the route's compiled-in upstream, regardless of slot. - routeMainInfo := mainUpstreamInfo + // The sandbox vhost, when present, is appended last; dispatch on position + // so equal vhost strings cannot misroute. + slot := mainSlot + if vi == sbIdx { + slot = sandboxSlot + } + + // Build route. Default is this route's own upstream (the slot's) — the single + // field exposed to the policy engine as the route's compiled-in upstream. + routeInfo := slot.defaultUpstream rdcRoute := &models.Route{ Method: method, Path: xds.ConstructFullPath(apiData.Context, apiData.Version, opPath), OperationPath: opPath, Vhost: vhost, - AutoHostRewrite: mainAutoHostRewrite, + AutoHostRewrite: slot.autoHostRewrite, MatchHeaders: headerMatches, PathMatchType: pathMatchType, Order: i, Timeout: routeTimeout, Upstream: models.RouteUpstream{ - ClusterKey: mainUpstream.ClusterKey, - UseClusterHeader: useClusterHeader, - DefaultCluster: defaultCluster, - Default: &routeMainInfo, + ClusterKey: slot.clusterKey, + UseClusterHeader: slot.useClusterHeader, + DefaultCluster: slot.defaultCluster, + Default: &routeInfo, }, } rdc.Routes[routeKey] = rdcRoute @@ -242,7 +290,9 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { continue } - defClusterKey := "upstream_" + cfg.Kind + "_" + cfg.UUID + "_" + SanitizeUpstreamDefinitionName(def.Name) + defClusterKey := clusterkey.DefinitionName(cfg.Kind, cfg.UUID, def.Name) + // Base path comes solely from the explicit basePath field; upstreamDefinitions + // URLs are host[:port] only (a path in the URL is rejected during validation). basePath := "/" if def.BasePath != nil && *def.BasePath != "" { basePath = *def.BasePath @@ -291,8 +341,9 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim } } - // Add sandbox upstream and update sandbox routes if present - if hasSandbox { + // Add sandbox upstream and update sandbox routes if present. + // API-level sandbox is optional when per-op sandbox overrides exist. + if apiSandboxHasContent { sbUpstream, err := t.addUpstreamCluster(rdc, "sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) if err != nil { return nil, fmt.Errorf("failed to resolve sandbox upstream: %w", err) @@ -304,10 +355,15 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim sbAutoHostRewrite = false } - // Update sandbox vhost routes to point to sandbox cluster. The route key must be - // derived with the same header-match discriminator used when the routes were built - // above, otherwise header-matched routes would not be found and re-pointed. + // Update sandbox vhost routes to point to sandbox cluster, except ops with + // their own per-op sandbox override (already wired in the main loop). The route + // key must be derived with the same header-match discriminator used when the + // routes were built above, otherwise header-matched routes would not be found + // and re-pointed. for _, op := range apiData.Operations { + if op.Upstream != nil && op.Upstream.Sandbox != nil { + continue + } discriminator := xds.HeaderMatchDiscriminator(routeHeaderMatches(op)) routeKey := xds.GenerateRouteNameWithDiscriminator(op.EffectiveMethod(), apiData.Context, apiData.Version, op.EffectivePath(), effectiveSandboxVHost, discriminator) if r, exists := rdc.Routes[routeKey]; exists { @@ -447,9 +503,8 @@ func (t *RestAPITransformer) buildPolicyChain( type upstreamClusterResult struct { // ClusterKey is the internal key used in rdc.UpstreamClusters. ClusterKey string - // EnvoyClusterName is the Envoy cluster name matching pkg/xds/translator.go's - // sanitizeClusterName format ("cluster__"). - // This is the value Envoy knows the cluster by, so PE must use it for x-target-upstream. + // EnvoyClusterName is the name Envoy knows the cluster by, used by the policy + // engine for the x-target-upstream header. It is always set equal to ClusterKey. EnvoyClusterName string // BasePath is the URL path component of the upstream (e.g. "/anything/foo"). BasePath string @@ -510,7 +565,9 @@ func (t *RestAPITransformer) addUpstreamCluster( connectTimeout = ct } - clusterKey := fmt.Sprintf("upstream_%s_%s_%d", upstreamName, parsedURL.Hostname(), port) + // URL-stable cluster name so a URL edit updates the same cluster instead of + // renaming it. ClusterKey and EnvoyClusterName are intentionally identical. + clusterKey := clusterkey.HashedName(upstreamName, rdc.Metadata.UUID) rdc.UpstreamClusters[clusterKey] = &models.UpstreamCluster{ BasePath: basePath, @@ -522,20 +579,53 @@ func (t *RestAPITransformer) addUpstreamCluster( ConnectTimeout: connectTimeout, } + // ClusterKey and EnvoyClusterName must stay identical or the default upstream + // path yields a 503 because Envoy cannot find the selected cluster. return &upstreamClusterResult{ ClusterKey: clusterKey, - EnvoyClusterName: sanitizeEnvoyClusterName(parsedURL.Host, parsedURL.Scheme), + EnvoyClusterName: clusterKey, BasePath: basePath, URL: fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host), }, nil } -// sanitizeEnvoyClusterName computes the Envoy cluster name from a URL host and scheme, -// matching the sanitizeClusterName logic in pkg/xds/translator.go. -func sanitizeEnvoyClusterName(host, scheme string) string { - name := strings.ReplaceAll(host, ".", "_") - name = strings.ReplaceAll(name, ":", "_") - return "cluster_" + scheme + "_" + name +// routeSlot carries the per-vhost route settings for one operation. +type routeSlot struct { + clusterKey string + useClusterHeader bool + defaultCluster string + autoHostRewrite bool + defaultUpstream policyenginev1.UpstreamInfo +} + +// applyPerOpRef points the slot at the referenced definition's cluster, keeping +// cluster_header on with that cluster as the default so a dynamic-endpoint policy +// can still steer the operation. autoHostRewrite keeps the API-level setting; +// per-op targets are ref-only with no HostRewrite field. +func (s *routeSlot) applyPerOpRef(env, kind, apiID, method, path, ref string, upstreamDefinitions *[]api.UpstreamDefinition) error { + def, err := upstreamref.FindByName(ref, upstreamDefinitions) + if err != nil { + return fmt.Errorf("per-op %s upstream for %s %s: %w", env, method, path, err) + } + if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { + return fmt.Errorf("per-op %s upstream for %s %s: upstream definition '%s' has no URLs configured", env, method, path, strings.TrimSpace(ref)) + } + defClusterKey := clusterkey.DefinitionName(kind, apiID, def.Name) + basePath := "/" + if def.BasePath != nil && *def.BasePath != "" { + basePath = *def.BasePath + } + s.clusterKey = defClusterKey + s.useClusterHeader = true + s.defaultCluster = defClusterKey + // This route's own compiled-in upstream is the referenced definition — + // exposed to the policy engine as the route's default upstream. + s.defaultUpstream = policyenginev1.UpstreamInfo{ + ClusterName: defClusterKey, + URL: strings.TrimSpace(def.Upstreams[0].Url), + BasePath: basePath, + } + return nil } // lookupUpstreamDefinition returns the upstream definition named ref (after trimming @@ -584,22 +674,20 @@ func resolveUpstreamURL(name string, up *api.Upstream, defs *[]api.UpstreamDefin } if up.Ref != nil && strings.TrimSpace(*up.Ref) != "" { refName := strings.TrimSpace(*up.Ref) - if defs == nil { - return "", nil, fmt.Errorf("upstream definition '%s' referenced but no definitions provided", refName) + // Resolve via the shared upstreamref helper and return the definition's + // basePath so the caller rewrites the upstream path correctly. + def, err := upstreamref.FindByName(refName, defs) + if err != nil { + return "", nil, err } - for _, def := range *defs { - if def.Name == refName { - if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { - return "", nil, fmt.Errorf("upstream definition '%s' has no URLs", refName) - } - basePath := "" - if def.BasePath != nil { - basePath = *def.BasePath - } - return def.Upstreams[0].Url, &basePath, nil - } + if len(def.Upstreams) == 0 || def.Upstreams[0].Url == "" { + return "", nil, fmt.Errorf("upstream definition '%s' has no URLs configured", refName) + } + basePath := "" + if def.BasePath != nil { + basePath = *def.BasePath } - return "", nil, fmt.Errorf("upstream definition '%s' not found", refName) + return def.Upstreams[0].Url, &basePath, nil } return "", nil, fmt.Errorf("%s upstream has no URL or ref", name) } @@ -618,13 +706,6 @@ func ResolvePort(u *url.URL) int { return 80 } -// SanitizeUpstreamDefinitionName replaces dots and colons for Envoy cluster name compatibility. -func SanitizeUpstreamDefinitionName(name string) string { - name = strings.ReplaceAll(name, ".", "_") - name = strings.ReplaceAll(name, ":", "_") - return name -} - // convertAPIPolicyToSDK converts an api.Policy to policyenginev1.PolicyInstance. func convertAPIPolicyToSDK(p api.Policy, attachedTo policyv1alpha.Level, resolvedVersion string) policyenginev1.PolicyInstance { paramsMap := make(map[string]interface{}) diff --git a/gateway/gateway-controller/pkg/transform/restapi_test.go b/gateway/gateway-controller/pkg/transform/restapi_test.go index 9a8606f2e4..a15d13afb7 100644 --- a/gateway/gateway-controller/pkg/transform/restapi_test.go +++ b/gateway/gateway-controller/pkg/transform/restapi_test.go @@ -29,11 +29,21 @@ import ( api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" ) // ptrStr is a helper to get a pointer to a string literal. func ptrStr(s string) *string { return &s } +// opRef builds the inline per-operation upstream target holding a ref. +func opRef(ref string) *struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` +} { + return &struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` + }{Ref: ref} +} + // testRouterCfg returns a minimal RouterConfig for transformer tests. func testRouterCfg() *config.RouterConfig { return &config.RouterConfig{ @@ -335,29 +345,6 @@ func TestRestAPITransformer_EmptyVersionUsesResolvedVersionInChain(t *testing.T) "resolved major version should be stored in the chain, not the original empty string") } -// TestSanitizeUpstreamDefinitionName verifies that dots and colons are replaced -// for Envoy cluster name compatibility. -func TestSanitizeUpstreamDefinitionName(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"my-upstream", "my-upstream"}, - {"my.upstream", "my_upstream"}, - {"my:upstream", "my_upstream"}, - {"host.example.com:8080", "host_example_com_8080"}, - {"", ""}, - {"a.b.c:d", "a_b_c_d"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := SanitizeUpstreamDefinitionName(tt.input) - assert.Equal(t, tt.expected, got) - }) - } -} - // TestResolveUpstreamURL verifies URL resolution from direct URL, ref, or missing config. func TestResolveUpstreamURL(t *testing.T) { refName := "my-def" @@ -437,6 +424,401 @@ func TestResolveUpstreamURL(t *testing.T) { }) } +// makeRestAPIWithOps builds a RestAPI StoredConfig with caller-supplied operations, +// both API-level main and sandbox upstreams configured, and a set of common +// upstreamDefinitions that per-op tests can reference by name. +func makeRestAPIWithOps(ops []api.Operation) *models.StoredConfig { + defs := []api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:8080"}}}, + {Name: "user-svc-test-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc-test:8080"}}}, + {Name: "shared-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://shared-svc:8080"}}}, + {Name: "same-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://same-svc:8080"}}}, + {Name: "user-svc-cluster-v2", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc:9090"}}}, + {Name: "per-op-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://per-op-main:9090"}}}, + } + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + Operations: ops, + UpstreamDefinitions: &defs, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: ptrStr("http://api-main:8080")}, + Sandbox: &api.Upstream{Url: ptrStr("http://api-sandbox:8080")}, + }, + } + restAPI := api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "test-api"}, + Spec: apiData, + } + return &models.StoredConfig{ + UUID: "test-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: restAPI, + } +} + +// TestRestAPITransformer_PerOpMainOverridesMainVhost asserts that a main-only override +// causes the main vhost route to use the definition cluster while the sandbox vhost route +// falls back to the API-level sandbox cluster. +func TestRestAPITransformer_PerOpMainOverridesMainVhost(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("user-svc-cluster"), + }, + }, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + require.NotNil(t, rdc) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute) + assert.Equal(t, clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-cluster"), mainRoute.Upstream.ClusterKey, + "main vhost should use the referenced definition cluster") + // Per-op main is dynamic: cluster_header ON with the definition cluster as the + // default, so a dynamic-endpoint policy can still steer it while a no-policy + // request falls back to the per-op ref. + assert.True(t, mainRoute.Upstream.UseClusterHeader, + "per-op main route should use cluster_header so policies can override") + assert.Equal(t, mainRoute.Upstream.ClusterKey, mainRoute.Upstream.DefaultCluster, + "per-op main DefaultCluster must be the definition cluster key") + + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandboxRoute) + assert.False(t, strings.HasPrefix(sandboxRoute.Upstream.ClusterKey, "upstream_"), + "sandbox vhost should fall back to API sandbox, got %q", sandboxRoute.Upstream.ClusterKey) +} + +// TestRestAPITransformer_PerOpSandboxOverridesSandboxVhost asserts that a sandbox-only override +// causes the main vhost to fall back to the API main while the sandbox vhost uses the definition cluster. +func TestRestAPITransformer_PerOpSandboxOverridesSandboxVhost(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("user-svc-test-cluster"), + }, + }, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute) + assert.False(t, strings.HasPrefix(mainRoute.Upstream.ClusterKey, "upstream_"), + "main vhost should fall back to API main, got %q", mainRoute.Upstream.ClusterKey) + + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandboxRoute) + assert.Equal(t, clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-test-cluster"), sandboxRoute.Upstream.ClusterKey, + "sandbox vhost should use the referenced definition cluster") +} + +// TestRestAPITransformer_PerOpBothOverrideBothVhosts asserts that both vhosts get distinct +// definition clusters when main and sandbox are overridden. +func TestRestAPITransformer_PerOpBothOverrideBothVhosts(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("user-svc-cluster"), + Sandbox: opRef("user-svc-test-cluster"), + }, + }, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, mainRoute) + require.NotNil(t, sandboxRoute) + + assert.Equal(t, clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-cluster"), mainRoute.Upstream.ClusterKey, + "main vhost should use its referenced definition cluster") + assert.Equal(t, clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-test-cluster"), sandboxRoute.Upstream.ClusterKey, + "sandbox vhost should use its referenced definition cluster") + assert.NotEqual(t, mainRoute.Upstream.ClusterKey, sandboxRoute.Upstream.ClusterKey, + "main and sandbox per-op vhosts must produce distinct cluster keys (definition names differ)") +} + +// TestRestAPITransformer_NoPerOpUsesAPILevelClusters - regression - without per-op +// upstream the routes still use the API-level main/sandbox clusters. +func TestRestAPITransformer_NoPerOpUsesAPILevelClusters(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, mainRoute) + require.NotNil(t, sandboxRoute) + assert.False(t, strings.HasPrefix(mainRoute.Upstream.ClusterKey, "upstream_")) + assert.False(t, strings.HasPrefix(sandboxRoute.Upstream.ClusterKey, "upstream_")) +} + +// TestRestAPITransformer_TwoOpsSameRefReuseOneCluster verifies the core reuse +// property: two operations referencing the SAME upstream definition reuse exactly +// ONE definition cluster (no per-op clusters), and both routes point at it. +func TestRestAPITransformer_TwoOpsSameRefReuseOneCluster(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), Upstream: &api.OperationUpstream{Main: opRef("shared-svc")}}, + {Method: api.Ptr(api.OperationMethod("POST")), Path: api.Ptr("/users"), Upstream: &api.OperationUpstream{Main: opRef("shared-svc")}}, + }) + spec := cfg.Configuration.(api.RestAPI) + spec.Spec.UpstreamDefinitions = &[]api.UpstreamDefinition{ + { + Name: "shared-svc", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://shared-svc:8080"}, + }, + }, + } + cfg.Configuration = spec + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + getRoute := rdc.Routes["GET|/test/users|main.local"] + postRoute := rdc.Routes["POST|/test/users|main.local"] + require.NotNil(t, getRoute, "GET route must exist") + require.NotNil(t, postRoute, "POST route must exist") + + // Both ops reuse the SAME definition cluster (no per-op clusters). + assert.Equal(t, getRoute.Upstream.ClusterKey, postRoute.Upstream.ClusterKey, + "two ops sharing a ref must reuse the same definition cluster") + assert.True(t, strings.HasPrefix(getRoute.Upstream.ClusterKey, "upstream_"), + "per-op route must reuse the upstream_ definition cluster, got %q", getRoute.Upstream.ClusterKey) + + // Exactly ONE cluster registered for shared-svc. + shared := 0 + for k := range rdc.UpstreamClusters { + if strings.Contains(k, "shared-svc") { + shared++ + } + } + assert.Equal(t, 1, shared, "shared-svc must produce exactly one reused definition cluster") +} + +// TestRestAPITransformer_PerOpClusterIsolatedAcrossAPIs asserts that two APIs with the +// same operation referencing the same definition produce different definition cluster +// keys because the API ID is part of the cluster name. +func TestRestAPITransformer_PerOpClusterIsolatedAcrossAPIs(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + cfgA := makeRestAPIWithOps([]api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("shared-svc-cluster"), + }, + }, + }) + cfgA.UUID = "api-aaa" + + cfgB := makeRestAPIWithOps([]api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("shared-svc-cluster"), + }, + }, + }) + cfgB.UUID = "api-bbb" + + rdcA, err := transformer.Transform(cfgA) + require.NoError(t, err) + rdcB, err := transformer.Transform(cfgB) + require.NoError(t, err) + + var keyA, keyB string + for k := range rdcA.UpstreamClusters { + if strings.HasPrefix(k, "upstream_") { + keyA = k + } + } + for k := range rdcB.UpstreamClusters { + if strings.HasPrefix(k, "upstream_") { + keyB = k + } + } + + require.NotEmpty(t, keyA) + require.NotEmpty(t, keyB) + assert.NotEqual(t, keyA, keyB, "same URL across different APIs must produce different definition cluster keys") +} + +// TestRestAPITransformer_PerOpSandboxWithoutAPILevelSandbox - guard regression. +// API-level Sandbox is nil, but one op declares a per-op sandbox upstream. The +// sandbox vhost must be created only for that op; ops without per-op sandbox +// must NOT get a sandbox route (otherwise they'd silently route to the main +// cluster on the sandbox vhost). +func TestRestAPITransformer_PerOpSandboxWithoutAPILevelSandbox(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + sbDefs := []api.UpstreamDefinition{ + {Name: "user-svc-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc-test:8080"}}}, + } + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + UpstreamDefinitions: &sbDefs, + Operations: []api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("user-svc-cluster"), + }, + }, + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/orders")}, + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: ptrStr("http://api-main:8080")}, + Sandbox: nil, + }, + } + cfg := &models.StoredConfig{ + UUID: "test-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "test-api"}, + Spec: apiData, + }, + } + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + require.NotNil(t, rdc) + + usersMain := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, usersMain, "op with per-op sandbox must still have a main route") + assert.False(t, strings.HasPrefix(usersMain.Upstream.ClusterKey, "upstream_"), + "main vhost should fall back to API main cluster, got %q", usersMain.Upstream.ClusterKey) + + usersSandbox := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, usersSandbox, "op with per-op sandbox must have a sandbox route") + assert.True(t, strings.HasPrefix(usersSandbox.Upstream.ClusterKey, "upstream_"), + "sandbox vhost should use definition cluster, got %q", usersSandbox.Upstream.ClusterKey) + + ordersMain := rdc.Routes["GET|/test/orders|main.local"] + require.NotNil(t, ordersMain, "op without per-op upstream must have a main route") + assert.False(t, strings.HasPrefix(ordersMain.Upstream.ClusterKey, "upstream_")) + + _, ordersHasSandbox := rdc.Routes["GET|/test/orders|sandbox.local"] + assert.False(t, ordersHasSandbox, + "op without per-op sandbox must NOT get a sandbox route when API-level sandbox is nil") +} + +// TestRestAPITransformer_PerOpSandboxInheritsSandboxHostRewrite - a per-op sandbox +// override route carries no HostRewrite of its own, so it must inherit the API-level +// SANDBOX HostRewrite (not the API-level main). This guards the transform/xDS parity: +// the xDS path inherits the sandbox value, so the RDC path must too. With API-level +// main=auto and sandbox=manual, the per-op sandbox route must be manual (AutoHostRewrite=false). +func TestRestAPITransformer_PerOpSandboxInheritsSandboxHostRewrite(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + manual := api.Manual + auto := api.Auto + defs := []api.UpstreamDefinition{ + {Name: "op-sandbox-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://op-sandbox:8080"}}}, + } + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "1.0.0", + UpstreamDefinitions: &defs, + Operations: []api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("op-sandbox-cluster"), + }, + }, + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: ptrStr("http://api-main:8080"), HostRewrite: &auto}, + Sandbox: &api.Upstream{Url: ptrStr("http://api-sandbox:8080"), HostRewrite: &manual}, + }, + } + cfg := &models.StoredConfig{ + UUID: "test-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "test-api"}, + Spec: apiData, + }, + } + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + usersSandbox := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, usersSandbox, "op with per-op sandbox must have a sandbox route") + assert.True(t, strings.HasPrefix(usersSandbox.Upstream.ClusterKey, "upstream_"), + "sandbox vhost should use definition cluster, got %q", usersSandbox.Upstream.ClusterKey) + assert.False(t, usersSandbox.AutoHostRewrite, + "per-op sandbox route must inherit API-level SANDBOX hostRewrite (manual), not main (auto)") + + usersMain := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, usersMain) + assert.True(t, usersMain.AutoHostRewrite, + "main route must keep API-level main hostRewrite (auto)") +} + // TestResolvePort checks port resolution with explicit, default-http and default-https. func TestResolvePort(t *testing.T) { tests := []struct { @@ -469,10 +851,6 @@ func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { defs := map[string]models.PolicyDefinition{} const sandboxURL = "http://sandbox-backend:9080/sandbox" const sandboxRouteKey = "GET|/test/hello|sandbox.local" - // The default cluster must be the name Envoy knows the cluster by, which in the - // RDC path is the rdc.UpstreamClusters map key (ClusterKey), i.e. - // "upstream_sandbox__" — not the sanitized "cluster__" form. - const expectedSandboxCluster = "upstream_sandbox_sandbox-backend_9080" t.Run("without upstreamDefinitions the sandbox route still uses cluster_header defaulting to the sandbox cluster", func(t *testing.T) { transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, defs) @@ -486,8 +864,8 @@ func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { r, exists := rdc.Routes[sandboxRouteKey] require.True(t, exists, "sandbox route should exist") assert.True(t, r.Upstream.UseClusterHeader) - assert.Equal(t, expectedSandboxCluster, r.Upstream.DefaultCluster, - "sandbox route must default to the sandbox cluster, not main") + assert.True(t, strings.HasPrefix(r.Upstream.DefaultCluster, "sandbox_"), + "sandbox route must default to the URL-stable sandbox cluster (sandbox_), not main; got %q", r.Upstream.DefaultCluster) }) t.Run("with upstreamDefinitions the sandbox route uses cluster_header defaulting to the sandbox cluster", func(t *testing.T) { @@ -503,8 +881,8 @@ func TestRestAPITransformer_SandboxRouteClusterHeader(t *testing.T) { r, exists := rdc.Routes[sandboxRouteKey] require.True(t, exists, "sandbox route should exist") assert.True(t, r.Upstream.UseClusterHeader) - assert.Equal(t, expectedSandboxCluster, r.Upstream.DefaultCluster, - "sandbox route must default to the sandbox cluster, not main") + assert.True(t, strings.HasPrefix(r.Upstream.DefaultCluster, "sandbox_"), + "sandbox route must default to the URL-stable sandbox cluster (sandbox_), not main; got %q", r.Upstream.DefaultCluster) }) } @@ -949,3 +1327,225 @@ func TestRestAPITransformer_ConnectTimeoutFromDefinition(t *testing.T) { } }) } + +// TestRestAPITransformer_APILevelClusterNameShape asserts the URL-stable cluster +// naming contract for API-level main and sandbox upstreams: +// - cluster names are "_<64-hex>": main and sandbox share the sha256(apiID) digest, distinguished by the env prefix +// - ClusterKey and EnvoyClusterName are the SAME string (so the policy engine's +// default_upstream_cluster metadata resolves to a real Envoy cluster) +func TestRestAPITransformer_APILevelClusterNameShape(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + // Expected name is hard-coded (full sha256("test-api")), not computed via + // clusterkey.HashedName, so a change to the hashing function is caught here. + expectedMain := "main_2a28373e2cacc6ea903d8c7e52dd3c49f8a87f95ec65ba1156de7e6564ca9524" + expectedSandbox := "sandbox_2a28373e2cacc6ea903d8c7e52dd3c49f8a87f95ec65ba1156de7e6564ca9524" + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute, "main route must exist") + assert.Equal(t, expectedMain, mainRoute.Upstream.ClusterKey, + "main cluster name should be _ derived from sha256(apiID)") + + sandboxRoute := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandboxRoute, "sandbox route must exist") + assert.Equal(t, expectedSandbox, sandboxRoute.Upstream.ClusterKey, + "sandbox cluster name should be _ derived from sha256(apiID)") + + _, mainExists := rdc.UpstreamClusters[expectedMain] + require.True(t, mainExists, "main cluster %q must be registered in UpstreamClusters", expectedMain) + _, sandboxExists := rdc.UpstreamClusters[expectedSandbox] + require.True(t, sandboxExists, "sandbox cluster %q must be registered in UpstreamClusters", expectedSandbox) +} + +// TestRestAPITransformer_APILevelDefaultClusterMatchesRealCluster verifies that +// route.Upstream.DefaultCluster matches a cluster registered in +// rdc.UpstreamClusters whenever UseClusterHeader is enabled. The policy engine +// writes DefaultCluster into the x-target-upstream header and Envoy looks up +// the cluster by that value; if the name does not match a registered cluster, +// Envoy returns a cluster-not-found 503. +func TestRestAPITransformer_APILevelDefaultClusterMatchesRealCluster(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}, + }) + // Add an upstreamDefinition so UseClusterHeader becomes true and + // DefaultCluster is actually populated. + spec := cfg.Configuration.(api.RestAPI) + spec.Spec.UpstreamDefinitions = &[]api.UpstreamDefinition{ + { + Name: "stub-def", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://stub-def-svc:8080"}, + }, + }, + } + cfg.Configuration = spec + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + mainRoute := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, mainRoute) + require.True(t, mainRoute.Upstream.UseClusterHeader, + "upstreamDefinitions present, UseClusterHeader should be true so DefaultCluster is meaningful") + require.NotEmpty(t, mainRoute.Upstream.DefaultCluster, + "DefaultCluster must be populated when UseClusterHeader is true") + + _, exists := rdc.UpstreamClusters[mainRoute.Upstream.DefaultCluster] + assert.True(t, exists, + "DefaultCluster %q must reference a real registered cluster in UpstreamClusters "+ + "(prevents a cluster-not-found 503 when the policy engine writes x-target-upstream)", + mainRoute.Upstream.DefaultCluster) + assert.Equal(t, mainRoute.Upstream.ClusterKey, mainRoute.Upstream.DefaultCluster, + "DefaultCluster and ClusterKey must be the same string") +} + +// TestRestAPITransformer_APILevelURLStableAcrossURLEdit asserts that editing the +// API-level main upstream URL does NOT change the cluster name. This is the +// URL-stable contract: the route keeps pointing at the same named cluster and +// name-keyed stats stay continuous across URL edits. +func TestRestAPITransformer_APILevelURLStableAcrossURLEdit(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + cfgA := makeRestAPIWithOps([]api.Operation{{Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}}) + rdcA, err := transformer.Transform(cfgA) + require.NoError(t, err) + + cfgB := makeRestAPIWithOps([]api.Operation{{Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}}) + specB := cfgB.Configuration.(api.RestAPI) + specB.Spec.Upstream.Main.Url = ptrStr("http://api-main-v2:9090") + cfgB.Configuration = specB + rdcB, err := transformer.Transform(cfgB) + require.NoError(t, err) + + nameA := rdcA.Routes["GET|/test/users|main.local"].Upstream.ClusterKey + nameB := rdcB.Routes["GET|/test/users|main.local"].Upstream.ClusterKey + assert.Equal(t, nameA, nameB, + "API-level main cluster name must not depend on URL "+ + "(URL-stable contract: the name must survive URL edits)") +} + +// TestRestAPITransformer_APILevelMainOnlyHasNoSandboxCluster verifies that an +// API with no sandbox upstream registers no sandbox_ cluster and creates +// no sandbox route. The optional env must not leave a route pointing at a +// cluster absent from UpstreamClusters (which would surface as a cluster-not-found 503). +func TestRestAPITransformer_APILevelMainOnlyHasNoSandboxCluster(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}, + }) + spec := cfg.Configuration.(api.RestAPI) + spec.Spec.Upstream.Sandbox = nil // main-only API + cfg.Configuration = spec + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + // Expected name is hard-coded (full sha256("test-api")), not computed via + // clusterkey.HashedName, so a change to the hashing function is caught here. + expectedMain := "main_2a28373e2cacc6ea903d8c7e52dd3c49f8a87f95ec65ba1156de7e6564ca9524" + expectedSandbox := "sandbox_2a28373e2cacc6ea903d8c7e52dd3c49f8a87f95ec65ba1156de7e6564ca9524" + + _, mainExists := rdc.UpstreamClusters[expectedMain] + require.True(t, mainExists, "main cluster %q must still be registered", expectedMain) + + _, sandboxExists := rdc.UpstreamClusters[expectedSandbox] + assert.False(t, sandboxExists, + "sandbox cluster %q must not be registered when no sandbox upstream is configured", expectedSandbox) + + _, sandboxRouteExists := rdc.Routes["GET|/test/users|sandbox.local"] + assert.False(t, sandboxRouteExists, + "no sandbox route should exist for a main-only API") +} + +// TestRestAPITransformer_ClusterNameUsesSharedHelper locks the cross-builder +// naming contract: the transform path names the cluster exactly +// clusterkey.HashedName(env, cfg.UUID), the same helper and argument the xDS +// translator uses (pinned on that side in pkg/xds tests), so the two builders +// cannot drift to different names for the same API. +func TestRestAPITransformer_ClusterNameUsesSharedHelper(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users")}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + assert.Equal(t, clusterkey.HashedName("main", cfg.UUID), + rdc.Routes["GET|/test/users|main.local"].Upstream.ClusterKey) + assert.Equal(t, clusterkey.HashedName("sandbox", cfg.UUID), + rdc.Routes["GET|/test/users|sandbox.local"].Upstream.ClusterKey) +} + +// TestRestAPITransformer_APILevelPolicyPrecedesOperationLevelInChain pins that +// buildPolicyChain places API-level policies before operation-level ones, so an +// operation-level policy is the last write and wins over an API-level one in the kernel. +func TestRestAPITransformer_APILevelPolicyPrecedesOperationLevelInChain(t *testing.T) { + defs := map[string]models.PolicyDefinition{ + "api-pol|v1.0.0": {Name: "api-pol", Version: "v1.0.0"}, + "op-pol|v1.0.0": {Name: "op-pol", Version: "v1.0.0"}, + } + + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, defs) + cfg := makeRestAPIStoredConfig( + []api.Policy{{Name: "api-pol", Version: ""}}, + []api.Policy{{Name: "op-pol", Version: ""}}, + ) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + require.NotNil(t, rdc) + + routeKey := "GET|/test/hello|main.local" + chain, ok := rdc.PolicyChains[routeKey] + require.True(t, ok) + require.Len(t, chain.Policies, 2) + assert.Equal(t, "api-pol", chain.Policies[0].Name, + "API-level policy must come first in the chain") + assert.Equal(t, "op-pol", chain.Policies[1].Name, + "operation-level policy must come after the API-level policy so it wins as the last write in the kernel") +} + +// TestRestAPITransformer_PerOpMainKeptWhenVhostsEqual pins that a per-op main override +// survives when the main and sandbox vhosts are the same string and no sandbox upstream +// exists; the route dispatch must key on the vhost's role, not its name. +func TestRestAPITransformer_PerOpMainKeptWhenVhostsEqual(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("user-svc-cluster"), + }, + }, + }) + restAPI := cfg.Configuration.(api.RestAPI) + restAPI.Spec.Upstream.Sandbox = nil + same := "same.local" + restAPI.Spec.Vhosts = &struct { + Main string `json:"main" yaml:"main"` + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: same, Sandbox: &same} + cfg.Configuration = restAPI + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + route := rdc.Routes["GET|/test/users|same.local"] + require.NotNil(t, route, "main route must exist") + want := clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-cluster") + assert.Equal(t, want, route.Upstream.ClusterKey, + "per-op main override must survive equal main/sandbox vhosts") + assert.Equal(t, want, route.Upstream.DefaultCluster, + "cluster_header default must be the per-op cluster, not the API-level one") +} diff --git a/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go new file mode 100644 index 0000000000..be1cb6d71e --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey.go @@ -0,0 +1,57 @@ +/* + * 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. + */ + +// Package clusterkey produces deterministic Envoy cluster names for the +// gateway-controller, shared by the RDC transformer and the xDS translator so +// they name clusters identically. +package clusterkey + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" +) + +// Hash returns the full SHA-256 hash in hex representation. +func Hash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +// HashedName joins a prefix string and the full SHA-256 hash of value with an underscore. +func HashedName(prefix, value string) string { + return prefix + "_" + Hash(value) +} + +// DefinitionName returns the full Envoy cluster name for an upstream definition, +// formatted as "upstream___". Dots and colons in the +// definition name are replaced so the result is a valid Envoy cluster name. The +// RDC transformer and the xDS translator use this so they name definition +// clusters identically. +func DefinitionName(kind, apiID, defName string) string { + return constants.UpstreamDefinitionClusterPrefix + kind + "_" + apiID + "_" + sanitizeDefName(defName) +} + +// sanitizeDefName replaces dots and colons, which are not allowed in Envoy cluster names. +func sanitizeDefName(name string) string { + name = strings.ReplaceAll(name, ".", "_") + name = strings.ReplaceAll(name, ":", "_") + return name +} diff --git a/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go new file mode 100644 index 0000000000..47ecfcb8a4 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/clusterkey/clusterkey_test.go @@ -0,0 +1,100 @@ +/* + * 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. + */ + +package clusterkey + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +// hexShape64 matches exactly 64 lowercase hex characters - the cluster-key +// fragment shape produced by Hash. +var hexShape64 = regexp.MustCompile("^[a-f0-9]{64}$") + +// TestHash validates the Hash helper: deterministic, distinct, and full SHA-256. +func TestHash(t *testing.T) { + t.Run("deterministic for identical input", func(t *testing.T) { + a := Hash("api-1") + b := Hash("api-1") + assert.Equal(t, a, b, "same input must produce same hash") + assert.Regexp(t, hexShape64, a, "hash must be exactly 64 lowercase hex characters") + }) + + t.Run("different input produces different hash", func(t *testing.T) { + a := Hash("api-1") + b := Hash("api-2") + assert.NotEqual(t, a, b) + }) + + // Known-answer vectors pin the algorithm to full SHA-256. + t.Run("known-answer vectors", func(t *testing.T) { + assert.Equal(t, "f9811b73ac5d1a8db842634fc0f871e03207ae44105fc9c2b7f1985e70f90d5a", Hash("api-1")) + assert.Equal(t, "2a28373e2cacc6ea903d8c7e52dd3c49f8a87f95ec65ba1156de7e6564ca9524", Hash("test-api")) + assert.Equal(t, "54a9b3e5ce2b6ccb97168e5948a66f48e084213b38eb8c7dc01c6f624a63c2f2", Hash("0190b3e2-7b1c-7c2a-9b3d-1a2b3c4d5e6f")) + }) + + t.Run("empty input is deterministic", func(t *testing.T) { + assert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", Hash("")) + }) +} + +// TestHashedName validates the full hashed name contract. +func TestHashedName(t *testing.T) { + t.Run("joins prefix to fragment", func(t *testing.T) { + assert.Equal(t, "main_"+Hash("api-1"), HashedName("main", "api-1")) + assert.Equal(t, "sandbox_"+Hash("api-1"), HashedName("sandbox", "api-1")) + }) + + t.Run("main and sandbox share the fragment, differ by prefix", func(t *testing.T) { + main := HashedName("main", "api-1") + sandbox := HashedName("sandbox", "api-1") + assert.NotEqual(t, main, sandbox) + assert.Equal(t, "main_f9811b73ac5d1a8db842634fc0f871e03207ae44105fc9c2b7f1985e70f90d5a", main) + assert.Equal(t, "sandbox_f9811b73ac5d1a8db842634fc0f871e03207ae44105fc9c2b7f1985e70f90d5a", sandbox) + }) +} + +// TestDefinitionName validates the upstream-definition cluster-name contract: the +// "upstream_" prefix, kind and API ID scoping, and dot/colon sanitization. The RDC +// transformer and the xDS translator both go through this helper, so per-op +// definition cluster names cannot drift. +func TestDefinitionName(t *testing.T) { + t.Run("format and scoping", func(t *testing.T) { + assert.Equal(t, "upstream_RestApi_api-1_my-upstream", DefinitionName("RestApi", "api-1", "my-upstream")) + }) + + t.Run("sanitizes dots and colons", func(t *testing.T) { + tests := []struct { + defName string + expected string + }{ + {"my.upstream", "upstream_RestApi_api-1_my_upstream"}, + {"my:upstream", "upstream_RestApi_api-1_my_upstream"}, + {"host.example.com:8080", "upstream_RestApi_api-1_host_example_com_8080"}, + {"a.b.c:d", "upstream_RestApi_api-1_a_b_c_d"}, + } + for _, tt := range tests { + t.Run(tt.defName, func(t *testing.T) { + assert.Equal(t, tt.expected, DefinitionName("RestApi", "api-1", tt.defName)) + }) + } + }) +} diff --git a/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go new file mode 100644 index 0000000000..4b740a5927 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref.go @@ -0,0 +1,97 @@ +/* + * 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. + */ + +// Package upstreamref centralizes resolution of per-op and API-level upstream +// references against the spec.upstreamDefinitions block. Both the xDS translator +// and the RDC transformer consume the same definitions and must agree on lookup +// and timeout-parsing semantics; this package exists so they share one source of +// truth. +package upstreamref + +import ( + "fmt" + "strings" + "time" + + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" +) + +// FindByName returns the UpstreamDefinition whose Name matches ref (after +// trimming whitespace). Returns an error if ref is empty, defs is nil/empty, or +// no matching definition exists. +func FindByName(ref string, defs *[]api.UpstreamDefinition) (*api.UpstreamDefinition, error) { + refName := strings.TrimSpace(ref) + if refName == "" { + return nil, fmt.Errorf("upstream ref is empty") + } + if defs == nil || len(*defs) == 0 { + return nil, fmt.Errorf("upstream definition '%s' referenced but no definitions provided", refName) + } + for i, def := range *defs { + if strings.TrimSpace(def.Name) == refName { + return &(*defs)[i], nil + } + } + return nil, fmt.Errorf("upstream definition '%s' not found", refName) +} + +// ParseConnectTimeout parses an UpstreamTimeout.Connect string. Empty/nil input +// returns (nil, nil). A parse failure or a non-positive duration returns an +// error so xDS and RDC paths fail consistently rather than silently dropping. +func ParseConnectTimeout(timeoutStr *string) (*time.Duration, error) { + if timeoutStr == nil { + return nil, nil + } + trimmed := strings.TrimSpace(*timeoutStr) + if trimmed == "" { + return nil, nil + } + d, err := time.ParseDuration(trimmed) + if err != nil { + return nil, fmt.Errorf("invalid timeout format: %w", err) + } + if d <= 0 { + return nil, fmt.Errorf("timeout must be positive, got: %v", d) + } + return &d, nil +} + +// HasContent returns true if the API-level upstream has non-empty configuration. +func HasContent(up *api.Upstream) bool { + if up == nil { + return false + } + return (up.Url != nil && strings.TrimSpace(*up.Url) != "") || + (up.Ref != nil && strings.TrimSpace(*up.Ref) != "") +} + +// SandboxActive returns true if the sandbox environment is active for the API. +// It is active if the API-level sandbox has content OR if any operation-level override has a sandbox ref. +func SandboxActive(sandbox *api.Upstream, ops []api.Operation) bool { + if HasContent(sandbox) { + return true + } + for _, op := range ops { + if op.Upstream != nil && op.Upstream.Sandbox != nil { + if strings.TrimSpace(op.Upstream.Sandbox.Ref) != "" { + return true + } + } + } + return false +} diff --git a/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go new file mode 100644 index 0000000000..9940dda054 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/upstreamref/upstreamref_test.go @@ -0,0 +1,203 @@ +/* + * 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. + */ + +package upstreamref + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" +) + +func TestFindByName_Found(t *testing.T) { + defs := &[]api.UpstreamDefinition{ + {Name: "users-svc"}, + {Name: "orders-svc"}, + } + def, err := FindByName("orders-svc", defs) + require.NoError(t, err) + require.NotNil(t, def) + assert.Equal(t, "orders-svc", def.Name) +} + +func TestFindByName_TrimsWhitespace(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + def, err := FindByName(" users-svc ", defs) + require.NoError(t, err) + assert.Equal(t, "users-svc", def.Name) +} + +func TestFindByName_EmptyRef(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + _, err := FindByName("", defs) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestFindByName_WhitespaceRef(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + _, err := FindByName(" ", defs) + require.Error(t, err) +} + +func TestFindByName_NilDefs(t *testing.T) { + _, err := FindByName("users-svc", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no definitions provided") +} + +func TestFindByName_EmptyDefs(t *testing.T) { + defs := &[]api.UpstreamDefinition{} + _, err := FindByName("users-svc", defs) + require.Error(t, err) + assert.Contains(t, err.Error(), "no definitions provided") +} + +func TestFindByName_NotFound(t *testing.T) { + defs := &[]api.UpstreamDefinition{{Name: "users-svc"}} + _, err := FindByName("orders-svc", defs) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestFindByName_ReturnsStablePointer(t *testing.T) { + defs := &[]api.UpstreamDefinition{ + {Name: "a"}, + {Name: "b"}, + {Name: "c"}, + } + got, err := FindByName("b", defs) + require.NoError(t, err) + assert.Same(t, &(*defs)[1], got, "must return pointer into the slice, not a copy of a loop variable") +} + +func TestParseConnectTimeout_NilInput(t *testing.T) { + d, err := ParseConnectTimeout(nil) + require.NoError(t, err) + assert.Nil(t, d) +} + +func TestParseConnectTimeout_EmptyString(t *testing.T) { + empty := "" + d, err := ParseConnectTimeout(&empty) + require.NoError(t, err) + assert.Nil(t, d) +} + +func TestParseConnectTimeout_WhitespaceOnly(t *testing.T) { + ws := " " + d, err := ParseConnectTimeout(&ws) + require.NoError(t, err) + assert.Nil(t, d) +} + +func TestParseConnectTimeout_Valid(t *testing.T) { + v := "5s" + d, err := ParseConnectTimeout(&v) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 5*time.Second, *d) +} + +func TestParseConnectTimeout_ValidMilliseconds(t *testing.T) { + v := "500ms" + d, err := ParseConnectTimeout(&v) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 500*time.Millisecond, *d) +} + +func TestParseConnectTimeout_ValidMinutesAndHours(t *testing.T) { + m := "2m" + d, err := ParseConnectTimeout(&m) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 2*time.Minute, *d) + + h := "1h" + d, err = ParseConnectTimeout(&h) + require.NoError(t, err) + require.NotNil(t, d) + assert.Equal(t, 1*time.Hour, *d) +} + +func TestParseConnectTimeout_Malformed(t *testing.T) { + v := "abc" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid timeout format") +} + +func TestParseConnectTimeout_NoUnit(t *testing.T) { + v := "30" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid timeout format") +} + +func TestParseConnectTimeout_Zero(t *testing.T) { + v := "0s" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be positive") +} + +func TestParseConnectTimeout_Negative(t *testing.T) { + v := "-5s" + _, err := ParseConnectTimeout(&v) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be positive") +} + +func ptrStr(s string) *string { + return &s +} + +func TestHasContent(t *testing.T) { + assert.False(t, HasContent(nil)) + assert.False(t, HasContent(&api.Upstream{})) + assert.False(t, HasContent(&api.Upstream{Url: ptrStr(""), Ref: ptrStr("")})) + assert.False(t, HasContent(&api.Upstream{Url: ptrStr(" ")})) + assert.False(t, HasContent(&api.Upstream{Ref: ptrStr(" ")})) + + assert.True(t, HasContent(&api.Upstream{Url: ptrStr("http://foo")})) + assert.True(t, HasContent(&api.Upstream{Ref: ptrStr("foo-svc")})) +} + +func TestSandboxActive(t *testing.T) { + // API-level sandbox has content -> active + assert.True(t, SandboxActive(&api.Upstream{Url: ptrStr("http://foo")}, nil)) + + // API-level sandbox is empty, but operations have sandbox override -> active + ops := []api.Operation{ + { + Upstream: &api.OperationUpstream{ + Sandbox: &struct { + Ref api.UpstreamReference "json:\"ref\" yaml:\"ref\"" + }{Ref: "op-sandbox-svc"}, + }, + }, + } + assert.True(t, SandboxActive(nil, ops)) + + // Both empty -> inactive + assert.False(t, SandboxActive(nil, []api.Operation{{}})) +} diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index ae27e6832d..f73ad1ab74 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -37,6 +37,8 @@ import ( "github.com/wso2/api-platform/common/collector" commonconstants "github.com/wso2/api-platform/common/constants" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/upstreamref" accesslog "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3" cluster "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" @@ -1024,7 +1026,7 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* clusters := []*cluster.Cluster{} // -------- MAIN UPSTREAM -------- - mainClusterName, parsedMainURL, mainTimeout, err := t.resolveUpstreamCluster("main", &apiData.Upstream.Main, apiData.UpstreamDefinitions) + mainClusterName, parsedMainURL, mainTimeout, err := t.resolveUpstreamCluster(cfg.UUID, "main", &apiData.Upstream.Main, apiData.UpstreamDefinitions) if err != nil { return nil, nil, err } @@ -1097,42 +1099,90 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* if err != nil { return nil, nil, fmt.Errorf("invalid resilience for operation %s %s: %w", op.EffectiveMethod(), op.EffectivePath(), err) } - opTimeoutCfg := combineRouteResilience(mainTimeout, apiTimeout, apiIdleTimeout, opTimeout, opIdleTimeout) + + params := routeParams{ + clusterName: mainClusterName, + urlPath: parsedMainURL.Path, + timeout: mainTimeout, + useClusterHeader: useClusterHeader, + } + if op.Upstream != nil && op.Upstream.Main != nil { + if err := t.applyPerOpRef(¶ms, "main", cfg.Kind, cfg.UUID, op.EffectiveMethod(), op.EffectivePath(), op.Upstream.Main.Ref, apiData.UpstreamDefinitions); err != nil { + return nil, nil, err + } + } + opTimeoutCfg := combineRouteResilience(params.timeout, apiTimeout, apiIdleTimeout, opTimeout, opIdleTimeout) r := t.createRoute(cfg.UUID, apiData.DisplayName, apiData.Version, apiData.Context, op.EffectiveMethod(), op.EffectivePath(), - mainClusterName, parsedMainURL.Path, effectiveMainVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Main.HostRewrite, apiProjectID, opTimeoutCfg, useClusterHeader, upstreamDefPaths) + params.clusterName, params.urlPath, effectiveMainVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Main.HostRewrite, apiProjectID, opTimeoutCfg, params.useClusterHeader, upstreamDefPaths) mainRoutesList = append(mainRoutesList, r) } routesList = append(routesList, mainRoutesList...) // -------- SANDBOX UPSTREAM -------- - if apiData.Upstream.Sandbox != nil { - sbClusterName, parsedSbURL, sbTimeout, err := t.resolveUpstreamCluster("sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) - if err != nil { - return nil, nil, err - } + apiSandboxHasContent := upstreamref.HasContent(apiData.Upstream.Sandbox) + hasSandbox := upstreamref.SandboxActive(apiData.Upstream.Sandbox, apiData.Operations) + if hasSandbox { + var sbClusterName string + var parsedSbURL *url.URL + var sbTimeout *resolvedTimeout + var sbRouteHostRewrite *api.UpstreamHostRewrite + + if apiSandboxHasContent { + sbClusterName, parsedSbURL, sbTimeout, err = t.resolveUpstreamCluster(cfg.UUID, "sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) + if err != nil { + return nil, nil, err + } - // Timeout for sandbox upstream cluster - var sbUpstreamClusterConnectTimeout *time.Duration - if sbTimeout != nil { - sbUpstreamClusterConnectTimeout = sbTimeout.Connect - } + // Timeout for sandbox upstream cluster + var sbUpstreamClusterConnectTimeout *time.Duration + if sbTimeout != nil { + sbUpstreamClusterConnectTimeout = sbTimeout.Connect + } - sandboxCluster := t.createCluster(sbClusterName, parsedSbURL, nil, sbUpstreamClusterConnectTimeout) - clusters = append(clusters, sandboxCluster) + sandboxCluster := t.createCluster(sbClusterName, parsedSbURL, nil, sbUpstreamClusterConnectTimeout) + clusters = append(clusters, sandboxCluster) + sbRouteHostRewrite = apiData.Upstream.Sandbox.HostRewrite + } else { + // Sandbox active via per-op only: inherit API-level main HostRewrite so per-op sandbox + // routes behave consistently with main routes. + sbRouteHostRewrite = apiData.Upstream.Main.HostRewrite + } // Create sandbox routes. Mirrors main's useClusterHeader (dynamic cluster selection - // is on whenever upstreamDefinitions exist or a sandbox upstream is configured). + // is on whenever upstreamDefinitions exist or a sandbox upstream is configured); + // a per-op sandbox ref reuses its definition cluster. sbRoutesList := make([]*route.Route, 0) + sbURLPath := "" + if parsedSbURL != nil { + sbURLPath = parsedSbURL.Path + } for _, op := range apiData.Operations { + // Skip ops without per-op sandbox when there's no API-level sandbox + if !apiSandboxHasContent && (op.Upstream == nil || op.Upstream.Sandbox == nil) { + continue + } + opTimeout, opIdleTimeout, err := ResolveResilience(op.Resilience) if err != nil { return nil, nil, fmt.Errorf("invalid resilience for operation %s %s: %w", op.EffectiveMethod(), op.EffectivePath(), err) } - opTimeoutCfg := combineRouteResilience(sbTimeout, apiTimeout, apiIdleTimeout, opTimeout, opIdleTimeout) + + params := routeParams{ + clusterName: sbClusterName, + urlPath: sbURLPath, + timeout: sbTimeout, + useClusterHeader: useClusterHeader, + } + if op.Upstream != nil && op.Upstream.Sandbox != nil { + if err := t.applyPerOpRef(¶ms, "sandbox", cfg.Kind, cfg.UUID, op.EffectiveMethod(), op.EffectivePath(), op.Upstream.Sandbox.Ref, apiData.UpstreamDefinitions); err != nil { + return nil, nil, err + } + } + opTimeoutCfg := combineRouteResilience(params.timeout, apiTimeout, apiIdleTimeout, opTimeout, opIdleTimeout) r := t.createRoute(cfg.UUID, apiData.DisplayName, apiData.Version, apiData.Context, op.EffectiveMethod(), op.EffectivePath(), - sbClusterName, parsedSbURL.Path, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, apiData.Upstream.Sandbox.HostRewrite, apiProjectID, opTimeoutCfg, useClusterHeader, upstreamDefPaths) + params.clusterName, params.urlPath, effectiveSandboxVHost, cfg.Kind, templateHandle, providerName, sbRouteHostRewrite, apiProjectID, opTimeoutCfg, params.useClusterHeader, upstreamDefPaths) sbRoutesList = append(sbRoutesList, r) } routesList = append(routesList, sbRoutesList...) @@ -1147,13 +1197,9 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* return nil, nil, fmt.Errorf("upstream definition '%s' has no URLs configured", def.Name) } - // Sanitize definition name for use in Envoy cluster name - // Envoy cluster names must not contain dots or colons - sanitizedDefName := sanitizeUpstreamDefinitionName(def.Name) - // Use the definition name as cluster name, scoped by kind and API ID to avoid conflicts // Format: upstream___ - defClusterName := constants.UpstreamDefinitionClusterPrefix + cfg.Kind + "_" + cfg.UUID + "_" + sanitizedDefName + defClusterName := clusterkey.DefinitionName(cfg.Kind, cfg.UUID, def.Name) // Parse the first URL from the definition rawURL := def.Upstreams[0].Url @@ -1193,9 +1239,11 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []* return routesList, clusters, nil } -// resolveUpstreamCluster validates an upstream (main or sandbox) and creates its cluster. -// Returns clusterName, parsedURL, timeout (can be nil), and error. -func (t *Translator) resolveUpstreamCluster(upstreamName string, up *api.Upstream, upstreamDefinitions *[]api.UpstreamDefinition) (string, *url.URL, *resolvedTimeout, error) { +// resolveUpstreamCluster validates an upstream (main or sandbox) and resolves the +// inputs its caller needs to create the cluster: clusterName, parsedURL, and +// timeout (can be nil). The cluster name is "_", +// URL-stable for the API's lifetime. +func (t *Translator) resolveUpstreamCluster(apiID, upstreamName string, up *api.Upstream, upstreamDefinitions *[]api.UpstreamDefinition) (string, *url.URL, *resolvedTimeout, error) { var rawURL string var timeout *resolvedTimeout var refBasePath *string @@ -1255,12 +1303,65 @@ func (t *Translator) resolveUpstreamCluster(upstreamName string, up *api.Upstrea parsedURL.Path = *refBasePath } - // Generate cluster name - clusterName := t.sanitizeClusterName(parsedURL.Host, parsedURL.Scheme) + // Generate cluster name from URL-stable hash (URL intentionally excluded). + clusterName := clusterkey.HashedName(upstreamName, apiID) return clusterName, parsedURL, timeout, nil } +// resolvePerOpDefinitionCluster resolves a per-op ref to the EXISTING +// upstream-definition cluster (created for every definition) and its base path, +// so a per-op route reuses that cluster instead of minting its own. +func (t *Translator) resolvePerOpDefinitionCluster(kind, apiID, ref string, upstreamDefinitions *[]api.UpstreamDefinition) (string, string, *resolvedTimeout, error) { + refName := strings.TrimSpace(ref) + if refName == "" { + return "", "", nil, fmt.Errorf("per-op upstream ref is empty") + } + definition, err := resolveUpstreamDefinition(refName, upstreamDefinitions) + if err != nil { + return "", "", nil, fmt.Errorf("failed to resolve per-op upstream ref: %w", err) + } + if len(definition.Upstreams) == 0 || definition.Upstreams[0].Url == "" { + return "", "", nil, fmt.Errorf("upstream definition '%s' has no URLs configured", refName) + } + + timeout, err := resolveTimeoutFromDefinition(definition) + if err != nil { + return "", "", nil, fmt.Errorf("invalid timeout in upstream definition '%s': %w", refName, err) + } + + basePath := "/" + if definition.BasePath != nil && *definition.BasePath != "" { + basePath = *definition.BasePath + } + clusterName := clusterkey.DefinitionName(kind, apiID, definition.Name) + return clusterName, basePath, timeout, nil +} + +// routeParams carries the per-route upstream settings for one operation. +type routeParams struct { + clusterName string + urlPath string + timeout *resolvedTimeout + useClusterHeader bool +} + +// applyPerOpRef points the params at the referenced definition's cluster (urlPath +// carries its base path) and keeps cluster_header on so a dynamic-endpoint policy +// can still steer the operation; when no policy overrides it, the policy engine +// falls back to this cluster. +func (t *Translator) applyPerOpRef(p *routeParams, env, kind, apiID, method, path, ref string, upstreamDefinitions *[]api.UpstreamDefinition) error { + defClusterName, defBasePath, defTimeout, err := t.resolvePerOpDefinitionCluster(kind, apiID, ref, upstreamDefinitions) + if err != nil { + return fmt.Errorf("per-op %s upstream for %s %s: %w", env, method, path, err) + } + p.clusterName = defClusterName + p.urlPath = defBasePath + p.timeout = defTimeout + p.useClusterHeader = true + return nil +} + // SharedRouteConfigName is the name of the shared route configuration used by both HTTP and HTTPS listeners const SharedRouteConfigName = "shared_route_config" @@ -2593,22 +2694,6 @@ func (t *Translator) pathToRegex(path string) string { return "^" + regex + "$" } -// sanitizeClusterName creates a valid cluster name from a hostname and scheme -func (t *Translator) sanitizeClusterName(hostname, scheme string) string { - name := strings.ReplaceAll(hostname, ".", "_") - name = strings.ReplaceAll(name, ":", "_") - // Include scheme to differentiate HTTP and HTTPS clusters for the same host - return "cluster_" + scheme + "_" + name -} - -// sanitizeUpstreamDefinitionName sanitizes an upstream definition name for use in Envoy cluster names. -// Envoy cluster names cannot contain dots or colons. -func sanitizeUpstreamDefinitionName(name string) string { - sanitized := strings.ReplaceAll(name, ".", "_") - sanitized = strings.ReplaceAll(sanitized, ":", "_") - return sanitized -} - // createAccessLogConfig creates access log configuration based on format (JSON or text) to stdout func (t *Translator) createAccessLogConfig() ([]*accesslog.AccessLog, error) { var accessLogs []*accesslog.AccessLog @@ -3191,39 +3276,16 @@ func (t *Translator) createExtProcFilter() (*hcm.HttpFilter, error) { }, nil } -// resolveUpstreamDefinition finds an upstream definition by its reference name -// Returns the upstream definition and error if not found +// resolveUpstreamDefinition finds an upstream definition by its reference name. +// Thin wrapper over upstreamref.FindByName to keep callers in this file unchanged. func resolveUpstreamDefinition(ref string, definitions *[]api.UpstreamDefinition) (*api.UpstreamDefinition, error) { - if definitions == nil { - return nil, fmt.Errorf("upstream definition '%s' not found: no definitions provided", ref) - } - - for _, def := range *definitions { - if def.Name == ref { - return &def, nil - } - } - - return nil, fmt.Errorf("upstream definition '%s' not found", ref) + return upstreamref.FindByName(ref, definitions) } -// parseTimeout parses a duration string (e.g., "30s", "1m", "500ms") and returns a time.Duration. -// Returns nil if the input is nil or empty. +// parseTimeout parses a duration string and returns a time.Duration. Thin wrapper +// over upstreamref.ParseConnectTimeout so xDS timeout parsing uses the shared parser. func parseTimeout(timeoutStr *string) (*time.Duration, error) { - if timeoutStr == nil || strings.TrimSpace(*timeoutStr) == "" { - return nil, nil - } - - duration, err := time.ParseDuration(strings.TrimSpace(*timeoutStr)) - if err != nil { - return nil, fmt.Errorf("invalid timeout format: %w", err) - } - - if duration <= 0 { - return nil, fmt.Errorf("timeout must be positive, got: %v", duration) - } - - return &duration, nil + return upstreamref.ParseConnectTimeout(timeoutStr) } // parseDurationAllowZero parses a duration string (e.g. "15s", "0s") into a *time.Duration. diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index 46321f7cdc..de88f25890 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -46,6 +46,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/clusterkey" ) func TestResolveUpstreamDefinition_Found(t *testing.T) { @@ -170,10 +171,11 @@ func TestResolveUpstreamCluster_WithDirectURL(t *testing.T) { Url: &url, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("main", upstream, nil) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) require.NoError(t, err) - assert.Equal(t, "cluster_http_backend_8080", clusterName) + assert.Equal(t, clusterkey.HashedName("main", "test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Equal(t, "http", parsedURL.Scheme) assert.Equal(t, "backend:8080", parsedURL.Host) @@ -207,10 +209,11 @@ func TestResolveUpstreamCluster_WithRef_WithTimeout(t *testing.T) { }, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("main", upstream, definitions) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) require.NoError(t, err) - assert.Equal(t, "cluster_http_backend-1_9000", clusterName) + assert.Equal(t, clusterkey.HashedName("main", "test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Equal(t, "http", parsedURL.Scheme) assert.Equal(t, "backend-1:9000", parsedURL.Host) @@ -240,10 +243,11 @@ func TestResolveUpstreamCluster_WithRef_NoTimeout(t *testing.T) { }, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("main", upstream, definitions) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) require.NoError(t, err) - assert.Equal(t, "cluster_http_backend_8080", clusterName) + assert.Equal(t, clusterkey.HashedName("main", "test-api"), clusterName, + "cluster name should be the URL-stable hash of the apiID, independent of URL") assert.NotNil(t, parsedURL) assert.Nil(t, timeout, "No timeout in definition should result in nil timeout") } @@ -268,7 +272,7 @@ func TestResolveUpstreamCluster_WithRef_NotFound(t *testing.T) { }, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, definitions) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to resolve main upstream ref") @@ -299,7 +303,7 @@ func TestResolveUpstreamCluster_WithRef_InvalidTimeout(t *testing.T) { }, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, definitions) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid timeout in upstream definition") @@ -321,7 +325,7 @@ func TestResolveUpstreamCluster_WithRef_NoURLs(t *testing.T) { }, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, definitions) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, definitions) assert.Error(t, err) assert.Contains(t, err.Error(), "has no URLs configured") @@ -331,7 +335,7 @@ func TestResolveUpstreamCluster_NoURLOrRef(t *testing.T) { translator := &Translator{} upstream := &api.Upstream{} - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, nil) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) assert.Error(t, err) assert.Contains(t, err.Error(), "no main upstream configured") @@ -344,7 +348,7 @@ func TestResolveUpstreamCluster_InvalidURL(t *testing.T) { Url: &invalidURL, } - _, _, _, err := translator.resolveUpstreamCluster("main", upstream, nil) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid main upstream URL") @@ -1060,52 +1064,6 @@ func TestSortRoutesByPriority_LegacyExactBeatsWildcardRegex(t *testing.T) { assert.Equal(t, wildcardKey, sorted[1].GetName()) } -func TestTranslator_SanitizeClusterName(t *testing.T) { - logger := createTestLogger() - routerCfg := testRouterConfig() - cfg := testConfig() - translator := NewTranslator(logger, routerCfg, nil, cfg) - - tests := []struct { - name string - hostname string - scheme string - expected string - }{ - { - name: "Simple hostname HTTP", - hostname: "localhost", - scheme: "http", - expected: "cluster_http_localhost", - }, - { - name: "Dotted hostname HTTPS", - hostname: "api.example.com", - scheme: "https", - expected: "cluster_https_api_example_com", - }, - { - name: "Hostname with port", - hostname: "localhost:8080", - scheme: "http", - expected: "cluster_http_localhost_8080", - }, - { - name: "Complex hostname", - hostname: "api.v1.prod.example.com:443", - scheme: "https", - expected: "cluster_https_api_v1_prod_example_com_443", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := translator.sanitizeClusterName(tt.hostname, tt.scheme) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestGetValueFromSourceConfig(t *testing.T) { tests := []struct { name string @@ -2378,7 +2336,7 @@ func TestTranslator_ResolveUpstreamCluster_SimpleURL(t *testing.T) { Url: &urlStr, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-upstream", upstream, nil) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "test-upstream", upstream, nil) assert.NoError(t, err) assert.NotEmpty(t, clusterName) assert.NotNil(t, parsedURL) @@ -2397,7 +2355,7 @@ func TestTranslator_ResolveUpstreamCluster_HTTPSUrl(t *testing.T) { Url: &urlStr, } - clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("secure-upstream", upstream, nil) + clusterName, parsedURL, timeout, err := translator.resolveUpstreamCluster("test-api", "secure-upstream", upstream, nil) assert.NoError(t, err) assert.NotEmpty(t, clusterName) assert.NotNil(t, parsedURL) @@ -2415,7 +2373,7 @@ func TestTranslator_ResolveUpstreamCluster_MissingURL(t *testing.T) { Url: nil, // No URL } - _, _, _, err := translator.resolveUpstreamCluster("no-url-upstream", upstream, nil) + _, _, _, err := translator.resolveUpstreamCluster("test-api", "no-url-upstream", upstream, nil) assert.Error(t, err) assert.Contains(t, err.Error(), "no no-url-upstream upstream configured") } @@ -3088,3 +3046,380 @@ func TestTranslateRuntimeConfig_PeerHostnameOnEveryEndpoint(t *testing.T) { } assert.Equal(t, 4, checked, "expected to have checked all 4 endpoints across both clusters (1 + 3)") } + +// TestResolveUpstreamCluster_NameStableAcrossURLs asserts the URL-stable +// contract at the API level. Two distinct URLs that share the same API ID and +// env must resolve to the same cluster name, so a URL edit updates the same +// named cluster instead of removing one cluster name and adding another. +func TestResolveUpstreamCluster_NameStableAcrossURLs(t *testing.T) { + translator := &Translator{} + a := &api.Upstream{Url: strPtr("http://api-main:8080")} + b := &api.Upstream{Url: strPtr("http://api-main:9090")} + + nameA, _, _, err := translator.resolveUpstreamCluster("test-api", "main", a, nil) + require.NoError(t, err) + nameB, _, _, err := translator.resolveUpstreamCluster("test-api", "main", b, nil) + require.NoError(t, err) + + assert.Equal(t, nameA, nameB, + "API-level cluster name must not depend on URL - same API and env must produce the same cluster") +} + +// TestResolveUpstreamCluster_NameNotURLDerived locks the move off the old +// URL-sanitized scheme: the cluster name must carry no URL information (no +// "cluster_" prefix, no host), only the env-prefixed identity hash. A +// regression to URL-derived naming would reintroduce connection draining on +// URL edits. +func TestResolveUpstreamCluster_NameNotURLDerived(t *testing.T) { + translator := &Translator{} + upstream := &api.Upstream{Url: strPtr("http://api.example.com:8080/v1")} + + name, _, _, err := translator.resolveUpstreamCluster("test-api", "main", upstream, nil) + require.NoError(t, err) + + assert.Equal(t, clusterkey.HashedName("main", "test-api"), name) + assert.False(t, strings.HasPrefix(name, "cluster_"), + "cluster name must not use the old URL-derived scheme") + assert.NotContains(t, name, "api.example.com", + "cluster name must not contain the backend host") +} + +// TestResolveUpstreamCluster_MainSandboxNeverCollide proves env separation: +// the same apiID with env=main vs env=sandbox must produce distinct cluster +// names so both vhosts can coexist. The names share the hash fragment (same +// API, so an operator can pair them at a glance); the env prefix provides +// the distinction. +func TestResolveUpstreamCluster_MainSandboxNeverCollide(t *testing.T) { + translator := &Translator{} + up := &api.Upstream{Url: strPtr("http://api-main:8080")} + + mainName, _, _, err := translator.resolveUpstreamCluster("test-api", "main", up, nil) + require.NoError(t, err) + sandboxName, _, _, err := translator.resolveUpstreamCluster("test-api", "sandbox", up, nil) + require.NoError(t, err) + + assert.NotEqual(t, mainName, sandboxName, + "main and sandbox cluster names must differ (the env prefix distinguishes them)") + assert.Equal(t, strings.TrimPrefix(mainName, "main_"), strings.TrimPrefix(sandboxName, "sandbox_"), + "main and sandbox must share the hash fragment so an API's cluster pair is correlatable") +} + +// A per-operation ref reuses the referenced upstream definition's cluster and +// inherits that definition's connect timeout. This asserts the timeout flows +// through the per-op resolution path specifically (not just the API-level path). +func TestResolvePerOpDefinitionCluster_InheritsDefinitionTimeout(t *testing.T) { + translator := &Translator{} + timeoutStr := "45s" + basePath := "/v2" + definitions := &[]api.UpstreamDefinition{ + { + Name: "my-svc", + BasePath: &basePath, + Timeout: &api.UpstreamTimeout{ + Connect: &timeoutStr, + }, + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{ + {Url: "http://backend-1:9000"}, + }, + }, + } + + clusterName, defBasePath, timeout, err := translator.resolvePerOpDefinitionCluster("RestApi", "test-api", "my-svc", definitions) + + require.NoError(t, err) + assert.Equal(t, constants.UpstreamDefinitionClusterPrefix+"RestApi_test-api_my-svc", clusterName, + "per-op route should reuse the upstream-definition cluster") + assert.Equal(t, "/v2", defBasePath, "per-op route inherits the definition basePath") + require.NotNil(t, timeout) + require.NotNil(t, timeout.Connect) + assert.Equal(t, 45*time.Second, *timeout.Connect, + "per-op ref must inherit the referenced definition's connect timeout") +} + +// TestTranslateConfigs_PerOpMainReusesDefinitionCluster asserts that a per-op main +// override reuses the referenced upstream-definition cluster on the legacy xDS path. +func TestTranslateConfigs_PerOpMainReusesDefinitionCluster(t *testing.T) { + translator := createTestTranslator() + + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "v1.0", + Vhosts: &struct { + Main string `json:"main" yaml:"main"` + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: "localhost", + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: strPtr("http://api-main:8080")}, + }, + UpstreamDefinitions: &[]api.UpstreamDefinition{ + {Name: "premium-svc", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://premium-svc:8080"}}}, + }, + Operations: []api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/premium"), + Upstream: &api.OperationUpstream{ + Main: opRef("premium-svc"), + }, + }, + }, + } + cfg := &models.StoredConfig{ + UUID: "main-op-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "main-op-api"}, + Spec: apiData, + }, + } + + resources, err := translator.TranslateConfigs([]*models.StoredConfig{cfg}, "test-correlation") + require.NoError(t, err) + require.NotNil(t, resources) + + clusters := resources[resource.ClusterType] + require.NotEmpty(t, clusters, "expected at least one cluster") + + var defClusterName string + for _, c := range clusters { + name := c.(*cluster.Cluster).GetName() + if strings.HasPrefix(name, "upstream_") && strings.Contains(name, "premium-svc") { + defClusterName = name + } + } + require.NotEmpty(t, defClusterName, + "expected the referenced upstream-definition cluster (upstream_..._premium-svc) to be emitted for the per-op main route") +} + +// TestTranslateConfigs_PerOpSandboxClusterEmitted asserts that the legacy xDS path +// emits the referenced upstream-definition cluster for a per-op sandbox upstream +// override, so the sandbox route can reuse it. +func TestTranslateConfigs_PerOpSandboxClusterEmitted(t *testing.T) { + translator := createTestTranslator() + + sbVhost := "sandbox.local" + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "v1.0", + Vhosts: &struct { + Main string `json:"main" yaml:"main"` + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: "localhost", + Sandbox: &sbVhost, + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: strPtr("http://api-main:8080")}, + }, + UpstreamDefinitions: &[]api.UpstreamDefinition{ + {Name: "user-svc-sb-cluster", Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://user-svc-sb:8080"}}}, + }, + Operations: []api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("user-svc-sb-cluster"), + }, + }, + }, + } + cfg := &models.StoredConfig{ + UUID: "sandbox-op-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "sandbox-op-api"}, + Spec: apiData, + }, + } + + resources, err := translator.TranslateConfigs([]*models.StoredConfig{cfg}, "test-correlation") + require.NoError(t, err) + require.NotNil(t, resources) + + clusters := resources[resource.ClusterType] + routeConfigs := resources[resource.RouteType] + require.NotEmpty(t, clusters, "expected at least one cluster") + require.NotEmpty(t, routeConfigs, "expected at least one route configuration") + + // Per-op sandbox REUSES the referenced definition's cluster + // (upstream___user-svc-sb-cluster). + var defClusterName string + for _, c := range clusters { + name := c.(*cluster.Cluster).GetName() + if strings.HasPrefix(name, "upstream_") && strings.Contains(name, "user-svc-sb-cluster") { + defClusterName = name + } + } + require.NotEmpty(t, defClusterName, + "expected the referenced upstream-definition cluster (upstream_..._user-svc-sb-cluster) to be emitted for reuse") + require.NotEmpty(t, routeConfigs, "expected sandbox route configuration to exist") +} + +// TestTranslateConfigs_PerOpRoutesUseClusterHeaderAndDefinitionBasePath asserts that a +// per-op main or sandbox ref produces an Envoy route wired for cluster_header dynamic +// routing (so a dynamic-endpoint policy can still steer it), strips the target-upstream +// header before forwarding, and rewrites the path with the referenced definition's base +// path. This covers the translateAPIConfig -> createRoute path, beyond just asserting the +// definition cluster is emitted. +func TestTranslateConfigs_PerOpRoutesUseClusterHeaderAndDefinitionBasePath(t *testing.T) { + assertPerOpRoute := func(t *testing.T, r *route.Route, defBasePath string) { + t.Helper() + require.NotNil(t, r, "expected the per-op route to be generated") + ra := r.GetRoute() + require.NotNil(t, ra, "per-op route must have a route action") + ch, ok := ra.ClusterSpecifier.(*route.RouteAction_ClusterHeader) + require.True(t, ok, "per-op route must use cluster_header dynamic routing, not a static cluster") + assert.Equal(t, constants.TargetUpstreamHeader, ch.ClusterHeader, + "per-op route must route via the target-upstream cluster header") + assert.Contains(t, r.RequestHeadersToRemove, constants.TargetUpstreamHeader, + "per-op route must strip the target-upstream header before forwarding upstream") + assert.Contains(t, ra.GetRegexRewrite().GetSubstitution(), defBasePath, + "per-op route must rewrite the path with the referenced definition base path") + } + + t.Run("per-op main ref", func(t *testing.T) { + translator := createTestTranslator() + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "v1.0", + Vhosts: &struct { + Main string `json:"main" yaml:"main"` + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: "localhost"}, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: api.Upstream{Url: strPtr("http://api-main:8080")}}, + UpstreamDefinitions: &[]api.UpstreamDefinition{ + {Name: "premium-svc", BasePath: strPtr("/premium-svc"), Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://premium-svc:8080"}}}, + }, + Operations: []api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/premium"), + Upstream: &api.OperationUpstream{ + Main: opRef("premium-svc"), + }}, + }, + } + cfg := &models.StoredConfig{ + UUID: "main-route-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "main-route-api"}, + Spec: apiData, + }, + } + + resources, err := translator.TranslateConfigs([]*models.StoredConfig{cfg}, "test-correlation") + require.NoError(t, err) + + var premiumRoute *route.Route + for _, rc := range resources[resource.RouteType] { + for _, vh := range rc.(*route.RouteConfiguration).GetVirtualHosts() { + for _, rt := range vh.GetRoutes() { + if strings.Contains(rt.GetMatch().GetSafeRegex().GetRegex(), "premium") { + premiumRoute = rt + } + } + } + } + assertPerOpRoute(t, premiumRoute, "/premium-svc") + }) + + t.Run("per-op sandbox ref without API-level sandbox", func(t *testing.T) { + translator := createTestTranslator() + sbVhost := "sandbox.local" + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "v1.0", + Vhosts: &struct { + Main string `json:"main" yaml:"main"` + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: "localhost", Sandbox: &sbVhost}, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: api.Upstream{Url: strPtr("http://api-main:8080")}}, + UpstreamDefinitions: &[]api.UpstreamDefinition{ + {Name: "sb-svc", BasePath: strPtr("/sb-svc"), Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://sb-svc:8080"}}}, + }, + Operations: []api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("sb-svc"), + }}, + }, + } + cfg := &models.StoredConfig{ + UUID: "sandbox-route-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "sandbox-route-api"}, + Spec: apiData, + }, + } + + resources, err := translator.TranslateConfigs([]*models.StoredConfig{cfg}, "test-correlation") + require.NoError(t, err) + + var sandboxRoute *route.Route + for _, rc := range resources[resource.RouteType] { + for _, vh := range rc.(*route.RouteConfiguration).GetVirtualHosts() { + matchesSandbox := false + for _, d := range vh.GetDomains() { + if strings.Contains(d, "sandbox.local") { + matchesSandbox = true + break + } + } + if !matchesSandbox { + continue + } + for _, rt := range vh.GetRoutes() { + if strings.Contains(rt.GetMatch().GetSafeRegex().GetRegex(), "users") { + sandboxRoute = rt + } + } + } + } + assertPerOpRoute(t, sandboxRoute, "/sb-svc") + }) +} + +// opRef builds the inline per-operation upstream target holding a ref. +func opRef(ref string) *struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` +} { + return &struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` + }{Ref: ref} +} diff --git a/gateway/gateway-controller/tests/integration/storage_test.go b/gateway/gateway-controller/tests/integration/storage_test.go index 7abd809f29..33452afb68 100644 --- a/gateway/gateway-controller/tests/integration/storage_test.go +++ b/gateway/gateway-controller/tests/integration/storage_test.go @@ -34,6 +34,15 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" ) +// opRef builds the inline per-operation upstream target holding a ref. +func opRef(ref string) *struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` +} { + return &struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` + }{Ref: ref} +} + // setupTestDB creates a temporary SQLite database for testing func setupTestDB(t *testing.T) (storage.Storage, string, func()) { t.Helper() @@ -762,3 +771,88 @@ func TestSQLiteStorage_LabelsPersistence(t *testing.T) { assert.Equal(t, labels, retrieved, "Loaded labels should match persisted labels") }) } + +func TestSQLiteStorage_PerOpUpstreamRefRoundTrip(t *testing.T) { + db, _, cleanup := setupTestDB(t) + defer cleanup() + + apiURL := "http://api-main:9080" + apiConfig := api.RestAPI{ + ApiVersion: api.RestAPIApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "PerOpRefAPI-v1.0"}, + Spec: api.APIConfigData{ + DisplayName: "PerOpRefAPI", + Version: "v1.0", + Context: "/per-op-ref", + UpstreamDefinitions: &[]api.UpstreamDefinition{ + { + Name: "users-svc", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://users-backend:9080"}}, + }, + { + Name: "users-sandbox-svc", + Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://users-sandbox:9080"}}, + }, + }, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: &apiURL}, + }, + Operations: []api.Operation{ + { + Method: api.Ptr(api.OperationMethodGET), + Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("users-svc"), + Sandbox: opRef("users-sandbox-svc"), + }, + }, + }, + }, + } + + cfg := &models.StoredConfig{ + UUID: uuid.New().String(), + Kind: string(api.RestAPIKindRestApi), + Handle: "PerOpRefAPI-v1.0", + DisplayName: "PerOpRefAPI", + Version: "v1.0", + Configuration: apiConfig, + SourceConfiguration: apiConfig, + DesiredState: models.StateDeployed, + Origin: models.OriginGatewayAPI, + } + + require.NoError(t, db.SaveConfig(cfg)) + + retrieved, err := db.GetConfig(cfg.UUID) + require.NoError(t, err) + + spec := retrieved.Configuration.(api.RestAPI).Spec + require.NotNil(t, spec.UpstreamDefinitions, "upstreamDefinitions must survive the round trip") + require.Len(t, *spec.UpstreamDefinitions, 2) + defs := *spec.UpstreamDefinitions + assert.Equal(t, "users-svc", defs[0].Name) + require.Len(t, defs[0].Upstreams, 1) + assert.Equal(t, "http://users-backend:9080", defs[0].Upstreams[0].Url) + assert.Equal(t, "users-sandbox-svc", defs[1].Name) + require.Len(t, defs[1].Upstreams, 1) + assert.Equal(t, "http://users-sandbox:9080", defs[1].Upstreams[0].Url) + require.Len(t, spec.Operations, 1) + + op := spec.Operations[0] + require.NotNil(t, op.Upstream, "per-op upstream must survive the round trip") + require.NotNil(t, op.Upstream.Main) + assert.Equal(t, "users-svc", op.Upstream.Main.Ref) + require.NotNil(t, op.Upstream.Sandbox) + assert.Equal(t, "users-sandbox-svc", op.Upstream.Sandbox.Ref) +} diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go b/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go index 0df3ef16b1..7d29ce892f 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/translator_test.go @@ -411,6 +411,59 @@ func TestBuildDynamicMetadata_WithPath(t *testing.T) { assert.Equal(t, "/new/path", extProc.Fields["path"].GetStringValue()) } +// ============================================================================= +// Per-op upstream + dynamic-endpoint precedence (regression: no double base prefix) +// ============================================================================= + +// TestTranslateRequestHeaderActions_DynamicEndpointDoesNotBakeBasePath guards the +// per-op-upstream-ref behavior. When a dynamic-endpoint policy redirects a request to an +// upstream definition that has a base path, the kernel must pass the original request path +// plus target_upstream_base_path so Lua prepends the base exactly once. +func TestTranslateRequestHeaderActions_DynamicEndpointDoesNotBakeBasePath(t *testing.T) { + kernel := NewKernel() + chainExecutor := executor.NewChainExecutor(nil, nil, nil) + server := NewExternalProcessorServer(kernel, chainExecutor, config.TracingConfig{}, "", testMaxDecompressedBytes, testMaxDecompressedBytes) + + chain := ®istry.PolicyChain{} + execCtx := newPolicyExecutionContext(server, "test-route", chain) + execCtx.sharedCtx = &policy.SharedContext{} + execCtx.requestBodyCtx = &policy.RequestContext{ + Path: "/per-op/v1.0/override", + SharedContext: execCtx.sharedCtx, + } + execCtx.apiContext = "/per-op/v1.0" + execCtx.upstreamBasePath = "/ref-svc" // the per-op route's default base path + execCtx.upstreamDefinitionPaths = map[string]string{ + "op-policy-svc": "/op-policy-svc", + } + + targetUpstream := "op-policy-svc" + result := &executor.RequestHeaderExecutionResult{ + Results: []executor.RequestHeaderPolicyResult{ + { + Action: policy.UpstreamRequestHeaderModifications{ + UpstreamName: &targetUpstream, + }, + }, + }, + } + + resp, err := TranslateRequestHeaderActions(result, chain, execCtx) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.DynamicMetadata) + + extProc := resp.DynamicMetadata.Fields[constants.ExtProcFilterName].GetStructValue() + require.NotNil(t, extProc) + + // The target upstream's base path is advertised so the Lua prepends it exactly once. + assert.Equal(t, "/op-policy-svc", extProc.Fields["target_upstream_base_path"].GetStringValue()) + // The ORIGINAL request path is handed to Lua via the single path metadata channel, + // not a pre-computed base-prefixed path. + assert.Equal(t, "/per-op/v1.0/override", extProc.Fields["path"].GetStringValue()) + assert.NotContains(t, extProc.Fields, "request_transformation.target_path") +} + // ============================================================================= // translateRequestActionsCore Tests // ============================================================================= @@ -1050,3 +1103,43 @@ func TestTranslateRequestHeaderActionsWithBodyMerge_DynamicEndpoint(t *testing.T assert.NotContains(t, extProc.Fields, "request_transformation.target_path") }) } + +// TestTranslateRequestHeaderActions_DynamicEndpointSanitizesClusterName pins the exact +// x-target-upstream name for a definition name containing dots or colons, locking it byte-for-byte to +// the controller's clusterkey.DefinitionName so the two modules' cluster names cannot drift. +func TestTranslateRequestHeaderActions_DynamicEndpointSanitizesClusterName(t *testing.T) { + kernel := NewKernel() + chainExecutor := executor.NewChainExecutor(nil, nil, nil) + server := NewExternalProcessorServer(kernel, chainExecutor, config.TracingConfig{}, "", testMaxDecompressedBytes, testMaxDecompressedBytes) + execCtx := newPolicyExecutionContext(server, "test-route", ®istry.PolicyChain{}) + execCtx.sharedCtx = &policy.SharedContext{APIKind: "RestApi", APIId: "api-123"} + execCtx.requestBodyCtx = &policy.RequestContext{ + Path: "/api/whoami", + SharedContext: execCtx.sharedCtx, + } + execCtx.apiContext = "/api" + execCtx.upstreamBasePath = "/sandbox" + execCtx.upstreamDefinitionPaths = map[string]string{"host.example.com:8080": "/alternate"} + + targetUpstream := "host.example.com:8080" + result := &executor.RequestHeaderExecutionResult{ + Results: []executor.RequestHeaderPolicyResult{ + {Action: policy.UpstreamRequestHeaderModifications{UpstreamName: &targetUpstream}}, + }, + } + + resp, err := TranslateRequestHeaderActions(result, ®istry.PolicyChain{}, execCtx) + require.NoError(t, err) + require.NotNil(t, resp) + + hm := resp.GetRequestHeaders().GetResponse().GetHeaderMutation() + require.NotNil(t, hm) + var headerValue string + for _, h := range hm.SetHeaders { + if h.Header.Key == constants.TargetUpstreamHeader { + headerValue = string(h.Header.RawValue) + } + } + assert.Equal(t, "upstream_RestApi_api-123_host_example_com_8080", headerValue, + "dots and colons in the definition name must be replaced with underscores to match the controller's cluster name") +} diff --git a/gateway/it/features/api-level-url-stable.feature b/gateway/it/features/api-level-url-stable.feature new file mode 100644 index 0000000000..c88eaf16b7 --- /dev/null +++ b/gateway/it/features/api-level-url-stable.feature @@ -0,0 +1,484 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +@api-level-url-stable +Feature: API-Level Upstream URL-Stable Cluster Naming + As an API developer + I want API-level main and sandbox cluster names to stay stable across + upstream URL edits + So that routes, name-keyed stats, and cluster identity survive URL changes + and requests keep succeeding during updates + + Background: + Given the gateway services are running + + Scenario: API-level main upstream URL update (host and path change) routes to new backend (URL-stable cluster naming) + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-main-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Main-API + version: v1.0 + context: /api-level-url-stable-main/$version + vhosts: + main: api-level-url-stable-main.local + upstream: + main: + url: http://sample-backend:9080/version-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" + + When I clear all headers + And I set request host to "api-level-url-stable-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-a/endpoint" + + # Envoy admin: the API-level cluster must use the identity-derived name + # (main_) and there must be no URL-derived (cluster__) + # cluster. The URL-derived form is what the pre-change naming produced, so + # this assertion fails on the old naming scheme. The exact name set is + # captured so the post-update step can prove the NAME survived the update. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And I capture the Envoy cluster names prefixed "main_" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-main-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-main-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Main-API + version: v1.0 + context: /api-level-url-stable-main/$version + vhosts: + main: api-level-url-stable-main.local + upstream: + main: + # The host changes too (container alias of the same backend), proving + # the cluster survives a HOST edit, not only a path edit. The old + # URL-derived naming kept its name across path edits but renamed the + # cluster on any host or scheme change. + url: http://it-sample-backend:9080/version-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" + + When I clear all headers + And I set request host to "api-level-url-stable-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-b/endpoint" + + # After the HOST change the exact cluster-name set must be UNCHANGED: + # this proves the same main_ cluster survived the host edit (a + # rename to a different main_ would fail the unchanged step). The + # old naming would have minted a new cluster_http_it-sample-backend_9080 + # cluster here and dropped the previous one. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "main_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-main-api-v1.0" + Then the response should be successful + + Scenario: API-level sandbox upstream URL update (host and path change) routes to new backend + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-sandbox-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Sandbox-API + version: v1.0 + context: /api-level-url-stable-sandbox/$version + vhosts: + main: api-level-url-stable-sandbox-main.local + sandbox: api-level-url-stable-sandbox-sb.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/sandbox-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-url-stable-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/sandbox-a/endpoint" + + # Capture the sandbox cluster-name set so the post-update step can prove + # the sandbox_ name survived the URL update. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And I capture the Envoy cluster names prefixed "sandbox_" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-sandbox-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-sandbox-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Sandbox-API + version: v1.0 + context: /api-level-url-stable-sandbox/$version + vhosts: + main: api-level-url-stable-sandbox-main.local + sandbox: api-level-url-stable-sandbox-sb.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + # The sandbox host changes too (container alias of the same + # backend), so this update exercises a host edit on the sandbox + # cluster, not only a path edit. + url: http://it-sample-backend:9080/sandbox-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-url-stable-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/sandbox-b/endpoint" + + # Envoy admin: the sandbox cluster must use the identity-derived name + # (sandbox_); no URL-derived cluster may exist, and the exact name + # set must be unchanged across the host edit (identity proof). Fails on + # the old URL-derived naming scheme. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "sandbox_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-sandbox-api-v1.0" + Then the response should be successful + + Scenario: API-level upstream with cluster_header routing (default upstream cluster resolves correctly) + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-default-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Default-API + version: v1.0 + context: /api-level-url-stable-default/$version + vhosts: + main: api-level-url-stable-default.local + upstreamDefinitions: + - name: backend-default + basePath: /api-main + upstreams: + - url: http://sample-backend:9080 + upstream: + main: + ref: backend-default + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" to be ready with host "api-level-url-stable-default.local" + + When I clear all headers + And I set request host to "api-level-url-stable-default.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-default-api-v1.0" + Then the response should be successful + + Scenario: API-level main and sandbox on the same backend host get separate identity-named clusters + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-collision-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Collision-API + version: v1.0 + context: /api-level-url-stable-collision/$version + vhosts: + main: api-level-url-stable-collision-main.local + sandbox: api-level-url-stable-collision-sb.local + upstream: + main: + url: http://sample-backend:9080/collision-main + sandbox: + url: http://sample-backend:9080/collision-sandbox + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" to be ready with host "api-level-url-stable-collision-main.local" + + # Main and sandbox share the same backend host:port but must route to their + # own base paths. The old URL-derived naming keyed the cluster on host and + # scheme only, so main and sandbox collapsed into one shared cluster here; + # identity naming gives each its own. + When I clear all headers + And I set request host to "api-level-url-stable-collision-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/collision-main/endpoint" + + When I clear all headers + And I set request host to "api-level-url-stable-collision-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/collision-sandbox/endpoint" + + # Envoy admin: an identity-named main_ and a sandbox_ cluster + # must both exist (they do not collide), and no URL-derived cluster may + # exist. Under the old naming both upstreams shared one cluster__ + # cluster, so this assertion fails on the previous scheme. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-collision-api-v1.0" + Then the response should be successful + + Scenario: Two APIs sharing the same backend host route independently through their own identity-named clusters + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-shared-a-v1.0 + spec: + displayName: API-Level-URL-Stable-Shared-A + version: v1.0 + context: /api-level-url-stable-shared-a/$version + vhosts: + main: api-level-url-stable-shared-a.local + upstream: + main: + url: http://sample-backend:9080/shared-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-a.local" + + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-shared-b-v1.0 + spec: + displayName: API-Level-URL-Stable-Shared-B + version: v1.0 + context: /api-level-url-stable-shared-b/$version + vhosts: + main: api-level-url-stable-shared-b.local + upstream: + main: + url: http://sample-backend:9080/shared-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-b.local" + + # Two distinct APIs point at the same backend host:port. The old URL-derived + # naming made them share one cluster__ cluster; identity naming + # keys each cluster on its API ID, so the two APIs route independently to their + # own base paths under identity-named clusters and no URL-derived cluster exists. + When I clear all headers + And I set request host to "api-level-url-stable-shared-a.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-a/endpoint" + + When I clear all headers + And I set request host to "api-level-url-stable-shared-b.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-b/endpoint" + + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + + # Delete API-B and confirm API-A still routes, proving the two APIs own + # independent clusters (deleting one does not disturb the other). + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-shared-b-v1.0" + Then the response should be successful + + When I clear all headers + And I set request host to "api-level-url-stable-shared-a.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-a/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-shared-a-v1.0" + Then the response should be successful + + Scenario: API-level main upstream scheme and port change keeps the same identity-named cluster + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-scheme-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Scheme-API + version: v1.0 + context: /api-level-url-stable-scheme/$version + vhosts: + main: api-level-url-stable-scheme.local + upstream: + main: + url: http://sample-backend:9080/version-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-scheme/v1.0/endpoint" to be ready with host "api-level-url-stable-scheme.local" + + # Capture the identity-derived cluster name while the upstream is plain http. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And I capture the Envoy cluster names prefixed "main_" + + # Change the upstream scheme (http -> https) AND port (9080 -> 9443) in one + # edit. The old URL-derived naming embedded scheme and port in the cluster + # name (cluster___), so this edit would have minted a new + # cluster_https_ cluster and dropped the previous one. Identity-based naming + # must keep the SAME main_ and never produce a cluster_https_. TLS + # routing itself is not asserted (there is no TLS echo backend); the cluster + # name is stable independent of upstream reachability. + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-scheme-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-scheme-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Scheme-API + version: v1.0 + context: /api-level-url-stable-scheme/$version + vhosts: + main: api-level-url-stable-scheme.local + upstream: + main: + url: https://sample-backend:9443/version-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + + # The main_ name set must be UNCHANGED after the scheme/port edit, and + # no URL-derived cluster_https_ may appear. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "main_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-scheme-api-v1.0" + Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-basic.feature b/gateway/it/features/per-op-upstream-basic.feature new file mode 100644 index 0000000000..6cb7b8b1c7 --- /dev/null +++ b/gateway/it/features/per-op-upstream-basic.feature @@ -0,0 +1,474 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +@per-op-upstream-basic +Feature: Per-Operation Upstream Basic Routing + As an API developer + I want per-operation upstream refs to override API-level upstreams + So that different operations can route to different backends + + Background: + Given the gateway services are running + + Scenario: API-level main fallback when operation has no per-op upstream + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-fm-api-v1.0 + spec: + displayName: Per-Op-Basic-FM-API + version: v1.0 + context: /per-op-basic-fm/$version + vhosts: + main: per-op-basic-fm-main.local + sandbox: per-op-basic-fm-sandbox.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-fm/v1.0/users" to be ready with host "per-op-basic-fm-main.local" + + When I clear all headers + And I set request host to "per-op-basic-fm-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-fm/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-fm-api-v1.0" + Then the response should be successful + + Scenario: API-level sandbox fallback when operation has no per-op upstream + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-fs-api-v1.0 + spec: + displayName: Per-Op-Basic-FS-API + version: v1.0 + context: /per-op-basic-fs/$version + vhosts: + main: per-op-basic-fs-main.local + sandbox: per-op-basic-fs-sandbox.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-fs/v1.0/users" to be ready with host "per-op-basic-fs-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-fs-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-fs/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-fs-api-v1.0" + Then the response should be successful + + Scenario: Per-operation main ref overrides API-level main + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-om-api-v1.0 + spec: + displayName: Per-Op-Basic-OM-API + version: v1.0 + context: /per-op-basic-om/$version + vhosts: + main: per-op-basic-om-main.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + - method: GET + path: /orders + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-om/v1.0/users" to be ready with host "per-op-basic-om-main.local" + + When I clear all headers + And I set request host to "per-op-basic-om-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-om/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-main/users" + + When I clear all headers + And I set request host to "per-op-basic-om-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-om/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-om-api-v1.0" + Then the response should be successful + + Scenario: Per-operation sandbox-only override routes sandbox traffic to the operation upstream + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-os-api-v1.0 + spec: + displayName: Per-Op-Basic-OS-API + version: v1.0 + context: /per-op-basic-os/$version + vhosts: + main: per-op-basic-os-main.local + sandbox: per-op-basic-os-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-os/v1.0/users" to be ready with host "per-op-basic-os-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-os-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-os/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-os-api-v1.0" + Then the response should be successful + + Scenario: Sandbox falls back when operation only has per-op main + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-sf-api-v1.0 + spec: + displayName: Per-Op-Basic-SF-API + version: v1.0 + context: /per-op-basic-sf/$version + vhosts: + main: per-op-basic-sf-main.local + sandbox: per-op-basic-sf-sandbox.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-sf/v1.0/users" to be ready with host "per-op-basic-sf-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-sf-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-sf/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-sf-api-v1.0" + Then the response should be successful + + Scenario: Main falls back when operation only has per-op sandbox + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-mf-api-v1.0 + spec: + displayName: Per-Op-Basic-MF-API + version: v1.0 + context: /per-op-basic-mf/$version + vhosts: + main: per-op-basic-mf-main.local + sandbox: per-op-basic-mf-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-mf/v1.0/users" to be ready with host "per-op-basic-mf-main.local" + + When I clear all headers + And I set request host to "per-op-basic-mf-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-mf/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-mf-api-v1.0" + Then the response should be successful + + Scenario: Operation with both per-op main and sandbox overrides + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-both-api-v1.0 + spec: + displayName: Per-Op-Basic-Both-API + version: v1.0 + context: /per-op-basic-both/$version + vhosts: + main: per-op-basic-both-main.local + sandbox: per-op-basic-both-sandbox.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-both/v1.0/users" to be ready with host "per-op-basic-both-main.local" + + When I clear all headers + And I set request host to "per-op-basic-both-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-both/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-main/users" + + When I clear all headers + And I set request host to "per-op-basic-both-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-both/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-both-api-v1.0" + Then the response should be successful + + Scenario: Per-operation upstream definition basePath update routes to the new path + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-eds-api-v1.0 + spec: + displayName: Per-Op-Basic-EDS-API + version: v1.0 + context: /per-op-basic-eds/$version + vhosts: + main: per-op-basic-eds-main.local + upstreamDefinitions: + - name: op-versioned-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /version-a + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /endpoint + upstream: + main: + ref: op-versioned-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" to be ready with host "per-op-basic-eds-main.local" + + When I clear all headers + And I set request host to "per-op-basic-eds-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-a/endpoint" + + Given I authenticate using basic auth as "admin" + When I update the API "per-op-basic-eds-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-eds-api-v1.0 + spec: + displayName: Per-Op-Basic-EDS-API + version: v1.0 + context: /per-op-basic-eds/$version + vhosts: + main: per-op-basic-eds-main.local + upstreamDefinitions: + - name: op-versioned-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /version-b + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /endpoint + upstream: + main: + ref: op-versioned-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" to be ready with host "per-op-basic-eds-main.local" + + When I clear all headers + And I set request host to "per-op-basic-eds-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-b/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-eds-api-v1.0" + Then the response should be successful + + Scenario: Per-op sandbox inherits API-level main hostRewrite when no API-level sandbox + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-sbhr-api-v1.0 + spec: + displayName: Per-Op-Basic-SBHR-API + version: v1.0 + context: /per-op-basic-sbhr/$version + vhosts: + main: per-op-basic-sbhr-main.local + sandbox: per-op-basic-sbhr-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://echo-backend:80 + basePath: /anything + upstream: + main: + url: http://echo-backend:80/anything + hostRewrite: manual + operations: + - method: GET + path: /test + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-sbhr/v1.0/test" to be ready with host "per-op-basic-sbhr-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-sbhr-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-sbhr/v1.0/test" + Then the response status code should be 200 + And the JSON response field "headers.Host" should be "per-op-basic-sbhr-sandbox.local" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-sbhr-api-v1.0" + Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-ref.feature b/gateway/it/features/per-op-upstream-ref.feature new file mode 100644 index 0000000000..666d9f1aff --- /dev/null +++ b/gateway/it/features/per-op-upstream-ref.feature @@ -0,0 +1,307 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +@per-op-upstream-ref +Feature: Per-Operation Upstream Ref + As an API developer + I want per-operation upstream refs to resolve through upstreamDefinitions + So that different operations can route to different backends + + Background: + Given the gateway services are running + + Scenario: Per-operation main refs route to different backend services on different ports + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-ref-api-v1.0 + spec: + displayName: Per-Op-Ref-API + version: v1.0 + context: /per-op/$version + vhosts: + main: per-op-main.local + upstreamDefinitions: + - name: users-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /user-svc + - name: orders-svc + upstreams: + - url: http://echo-backend:80 + basePath: /anything + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: users-svc + - method: GET + path: /orders + upstream: + main: + ref: orders-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op/v1.0/users" to be ready with host "per-op-main.local" + And I wait for the endpoint "http://localhost:8080/per-op/v1.0/orders" to be ready with host "per-op-main.local" + + When I clear all headers + And I set request host to "per-op-main.local" + And I send a GET request to "http://localhost:8080/per-op/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/user-svc/users" + + When I clear all headers + And I set request host to "per-op-main.local" + And I send a GET request to "http://localhost:8080/per-op/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "url" should be "http://echo-backend/anything/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-ref-api-v1.0" + Then the response should be successful + + Scenario: Mixed operations - one with per-op ref, one falling back to API-level + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-mixed-api-v1.0 + spec: + displayName: Per-Op-Mixed-API + version: v1.0 + context: /per-op-mixed/$version + vhosts: + main: per-op-mixed-main.local + upstreamDefinitions: + - name: users-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /user-svc + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + main: + ref: users-svc + - method: GET + path: /orders + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-mixed/v1.0/users" to be ready with host "per-op-mixed-main.local" + + When I clear all headers + And I set request host to "per-op-mixed-main.local" + And I send a GET request to "http://localhost:8080/per-op-mixed/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/user-svc/users" + + When I clear all headers + And I set request host to "per-op-mixed-main.local" + And I send a GET request to "http://localhost:8080/per-op-mixed/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-mixed-api-v1.0" + Then the response should be successful + + Scenario: Operation-level dynamic-endpoint policy overrides the per-op ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-prec-op-api-v1.0 + spec: + displayName: Per-Op-Prec-Op-API + version: v1.0 + context: /per-op-prec-op/$version + vhosts: + main: per-op-prec-op-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + - name: op-policy-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-policy-svc + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /override + upstream: + main: + ref: ref-svc + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: op-policy-svc + - method: GET + path: /fallback + upstream: + main: + ref: ref-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-prec-op/v1.0/fallback" to be ready with host "per-op-prec-op-main.local" + + # Operation-level dynamic-endpoint policy wins over the per-op ref. + When I clear all headers + And I set request host to "per-op-prec-op-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-op/v1.0/override" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-policy-svc/override" + + # No policy on this op: the per-op ref is the default. + When I clear all headers + And I set request host to "per-op-prec-op-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-op/v1.0/fallback" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/ref-svc/fallback" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-prec-op-api-v1.0" + Then the response should be successful + + Scenario: API-level dynamic-endpoint policy overrides the per-op ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-prec-api-api-v1.0 + spec: + displayName: Per-Op-Prec-Api-API + version: v1.0 + context: /per-op-prec-api/$version + vhosts: + main: per-op-prec-api-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + - name: global-policy-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /global-policy-svc + upstream: + main: + url: http://sample-backend:9080/api-main + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: global-policy-svc + operations: + - method: GET + path: /items + upstream: + main: + ref: ref-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-prec-api/v1.0/items" to be ready with host "per-op-prec-api-main.local" + + # API-level dynamic-endpoint policy wins over the per-op ref (dynamic beats static upstream). + When I clear all headers + And I set request host to "per-op-prec-api-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-api/v1.0/items" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/global-policy-svc/items" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-prec-api-api-v1.0" + Then the response should be successful + + Scenario: Request-rewrite policy composes with a per-op ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-ref-rewrite-api-v1.0 + spec: + displayName: Per-Op-Ref-Rewrite-API + version: v1.0 + context: /per-op-ref-rewrite/$version + vhosts: + main: per-op-ref-rewrite-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /whoami + upstream: + main: + ref: ref-svc + policies: + - name: request-rewrite + version: v1 + params: + pathRewrite: + type: ReplaceFullPath + replaceFullPath: /rewritten + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-ref-rewrite/v1.0/whoami" to be ready with host "per-op-ref-rewrite-main.local" + + When I clear all headers + And I set request host to "per-op-ref-rewrite-main.local" + And I send a GET request to "http://localhost:8080/per-op-ref-rewrite/v1.0/whoami" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/ref-svc/rewritten" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-ref-rewrite-api-v1.0" + Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-validation.feature b/gateway/it/features/per-op-upstream-validation.feature new file mode 100644 index 0000000000..82ea61762d --- /dev/null +++ b/gateway/it/features/per-op-upstream-validation.feature @@ -0,0 +1,202 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +@per-op-upstream-validation +Feature: Per-Operation Upstream Validation + As an API developer + I want malformed per-operation upstream configurations to be rejected + So that invalid APIs cannot be deployed + + Background: + Given the gateway services are running + + Scenario: Empty per-op upstream wrapper is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-empty-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-API + version: v1.0 + context: /per-op-val-empty/$version + vhosts: + main: per-op-val-empty-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: {} + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "At least one of 'main' or 'sandbox' must be set" + + Scenario: Per-op ref to non-existent upstream definition is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-missing-ref-api-v1.0 + spec: + displayName: Per-Op-Val-Missing-Ref-API + version: v1.0 + context: /per-op-val-missing-ref/$version + vhosts: + main: per-op-val-missing-ref-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: does-not-exist + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Referenced upstream definition 'does-not-exist' not found" + + Scenario: Empty per-op leaf is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-empty-leaf-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-Leaf-API + version: v1.0 + context: /per-op-val-empty-leaf/$version + vhosts: + main: per-op-val-empty-leaf-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: {} + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Upstream ref is required" + + Scenario: Empty per-op sandbox leaf with no API-level sandbox is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-empty-sandbox-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-Sandbox-API + version: v1.0 + context: /per-op-val-empty-sandbox/$version + vhosts: + main: per-op-val-empty-sandbox-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + sandbox: {} + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Upstream ref is required" + + Scenario: Per-op ref with invalid characters is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-bad-ref-api-v1.0 + spec: + displayName: Per-Op-Val-Bad-Ref-API + version: v1.0 + context: /per-op-val-bad-ref/$version + vhosts: + main: per-op-val-bad-ref-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: "bad/ref!" + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "must match pattern" + + Scenario: Zero connect timeout in an upstreamDefinition is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-neg-timeout-api-v1.0 + spec: + displayName: Per-Op-Val-Neg-Timeout-API + version: v1.0 + context: /per-op-val-neg-timeout/$version + vhosts: + main: per-op-val-neg-timeout-main.local + upstreamDefinitions: + - name: slow-svc + timeout: + connect: 0s + upstreams: + - url: http://sample-backend:9080 + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: slow-svc + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Connect timeout must be a positive duration" diff --git a/gateway/it/steps_envoy_admin.go b/gateway/it/steps_envoy_admin.go new file mode 100644 index 0000000000..42bbb6fc6b --- /dev/null +++ b/gateway/it/steps_envoy_admin.go @@ -0,0 +1,118 @@ +/* + * 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. + */ + +package it + +import ( + "context" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/cucumber/godog" +) + +// envoyAdminURL is the Envoy admin endpoint exposed by the IT compose stack. +const envoyAdminURL = "http://localhost:9901" + +// envoyAdminClient bounds admin queries so a stalled admin endpoint fails the +// step instead of hanging until the suite timeout. +var envoyAdminClient = &http.Client{Timeout: 10 * time.Second} + +// rememberedClusterSets holds cluster-name sets captured during a scenario, +// keyed by name prefix. Cleared before each scenario. Safe under godog's +// default sequential execution; it would need per-scenario state if scenario +// parallelism is ever enabled. +var rememberedClusterSets = map[string][]string{} + +// fetchEnvoyClusterNames returns the sorted, de-duplicated set of cluster +// names with the given prefix, parsed from the Envoy admin /clusters output +// (each line has the form "::::"). +func fetchEnvoyClusterNames(prefix string) ([]string, error) { + resp, err := envoyAdminClient.Get(envoyAdminURL + "/clusters") + if err != nil { + return nil, fmt.Errorf("failed to query Envoy admin /clusters: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Envoy admin /clusters returned status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read Envoy admin /clusters response: %w", err) + } + seen := map[string]bool{} + for _, line := range strings.Split(string(body), "\n") { + name, _, ok := strings.Cut(line, "::") + if !ok { + continue + } + if strings.HasPrefix(name, prefix) { + seen[name] = true + } + } + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + return names, nil +} + +// RegisterEnvoyAdminSteps registers steps that assert Envoy cluster identity +// via the admin endpoint. Capturing the exact cluster-name set before an API +// update and asserting it is unchanged afterwards proves the cluster NAME +// survived the update; substring checks on /clusters alone cannot prove that +// (an implementation renaming one hashed cluster to another would still pass +// a "contains prefix" check). +func RegisterEnvoyAdminSteps(ctx *godog.ScenarioContext) { + ctx.Before(func(c context.Context, sc *godog.Scenario) (context.Context, error) { + rememberedClusterSets = map[string][]string{} + return c, nil + }) + + ctx.Step(`^I capture the Envoy cluster names prefixed "([^"]*)"$`, func(prefix string) error { + names, err := fetchEnvoyClusterNames(prefix) + if err != nil { + return err + } + if len(names) == 0 { + return fmt.Errorf("no Envoy clusters with prefix %q found to capture", prefix) + } + rememberedClusterSets[prefix] = names + return nil + }) + + ctx.Step(`^the Envoy cluster names prefixed "([^"]*)" should be unchanged$`, func(prefix string) error { + captured, ok := rememberedClusterSets[prefix] + if !ok { + return fmt.Errorf("no captured cluster set for prefix %q; use the capture step first", prefix) + } + current, err := fetchEnvoyClusterNames(prefix) + if err != nil { + return err + } + if strings.Join(captured, ",") != strings.Join(current, ",") { + return fmt.Errorf("Envoy cluster set with prefix %q changed across the update: before=%v after=%v (cluster identity must be stable)", prefix, captured, current) + } + return nil + }) +} diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index beff2452dc..4c078aae2b 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -151,6 +151,10 @@ func getFeaturePaths() []string { "features/upstream-connect-timeout.feature", "features/backend-timeout.feature", "features/llm-backend-timeout.feature", + "features/per-op-upstream-basic.feature", + "features/per-op-upstream-ref.feature", + "features/per-op-upstream-validation.feature", + "features/api-level-url-stable.feature", // Runs late: it restarts the gateway-controller (reject/reconnect scenario), so keep it // after features that assume an uninterrupted controller. Verifies the DP->CP artifact push. "features/dp-to-cp.feature", @@ -356,6 +360,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { RegisterSubscriptionSteps(ctx, testState, httpSteps) RegisterSecretSteps(ctx, testState, httpSteps) RegisterTemplateSteps(ctx, testState, httpSteps) + RegisterEnvoyAdminSteps(ctx) RegisterDPToCPSteps(ctx, testState) } diff --git a/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md b/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md index 86d3d23e94..03c5e845ae 100644 --- a/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md +++ b/gateway/spec/impls/1-basic-gateway-with-controller/data-model.md @@ -661,8 +661,8 @@ For each API Configuration, create a RouteConfiguration: - **Routes**: One route per operation mapping `{method, path}` to cluster #### Cluster Creation -For each unique upstream URL, create a Cluster: -- **Name**: `cluster_{sanitized_upstream_url}` (e.g., `cluster_api_weather_com`) +For each API-level upstream (main/sandbox), create a Cluster: +- **Name**: `{env}_{hash}` where `hash` is the full hexadecimal SHA-256 digest of the API ID (e.g., `main_f9811b73ac5d1a8db842634fc0f871e03207ae44105fc9c2b7f1985e70f90d5a`). Main and sandbox share the digest and are distinguished by the env prefix. The name is derived from the API's identity, not the URL, so a URL edit never renames the cluster. Host, port, or scheme changes are applied as an update to the same named cluster (warmed and swapped); path-only changes touch just the route rewrite. Routes and name-keyed stats stay continuous either way. - **Type**: `STRICT_DNS` or `LOGICAL_DNS` - **Load Assignment**: Endpoint with upstream host and port @@ -691,9 +691,9 @@ data: - **Listener**: `listener_http_8080` listening on `0.0.0.0:8080` - **Route**: `route_weather_api_v1_0` - Match: `GET /weather/{country_code}/{city}` - - Action: Forward to `cluster_api_weather_com` + - Action: Forward to `main_` (the API's main upstream cluster) - Prefix Rewrite: Prepend `/api/v2` → final path: `/api/v2/{country_code}/{city}` -- **Cluster**: `cluster_api_weather_com` +- **Cluster**: `main_` (identity-derived name, stable across URL edits) - Host: `api.weather.com:443` - TLS: Enabled (HTTPS upstream) From b96da0684ef7d069f8c143f9b6d2caaeb35b71ab Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Mon, 13 Jul 2026 11:42:53 +0530 Subject: [PATCH 2/9] test(gateway): observe Envoy cluster stability over a settle window --- .../pkg/config/api_validator.go | 4 ++-- .../it/features/api-level-url-stable.feature | 6 +++-- gateway/it/steps_envoy_admin.go | 24 +++++++++++++------ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index 239786a5c4..dbc3379945 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -38,8 +38,8 @@ type APIValidator struct { versionRegex *regexp.Regexp // urlFriendlyNameRegex matches URL-safe characters for API names urlFriendlyNameRegex *regexp.Regexp - // upstreamRefRegex enforces the schema pattern shared by upstream refs - // (API-level and per-op) and upstream definition names + // upstreamRefRegex enforces the schema pattern for API-level and per-op + // upstream refs upstreamRefRegex *regexp.Regexp // policyValidator validates policy references and parameters policyValidator *PolicyValidator diff --git a/gateway/it/features/api-level-url-stable.feature b/gateway/it/features/api-level-url-stable.feature index c88eaf16b7..af7e9c19e5 100644 --- a/gateway/it/features/api-level-url-stable.feature +++ b/gateway/it/features/api-level-url-stable.feature @@ -217,7 +217,7 @@ Feature: API-Level Upstream URL-Stable Cluster Naming When I delete the API "api-level-url-stable-sandbox-api-v1.0" Then the response should be successful - Scenario: API-level upstream with cluster_header routing (default upstream cluster resolves correctly) + Scenario: API-level upstream ref resolves to the referenced upstreamDefinitions entry Given I authenticate using basic auth as "admin" When I deploy this API configuration: """ @@ -445,7 +445,9 @@ Feature: API-Level Upstream URL-Stable Cluster Naming # name (cluster___), so this edit would have minted a new # cluster_https_ cluster and dropped the previous one. Identity-based naming # must keep the SAME main_ and never produce a cluster_https_. TLS - # routing itself is not asserted (there is no TLS echo backend); the cluster + # routing itself is not asserted (there is no TLS echo backend), so there is no + # endpoint-readiness wait here; the cluster-set check below observes a settle + # window instead to cover xDS propagation. The cluster # name is stable independent of upstream reachability. Given I authenticate using basic auth as "admin" When I update the API "api-level-url-stable-scheme-api-v1.0" with this configuration: diff --git a/gateway/it/steps_envoy_admin.go b/gateway/it/steps_envoy_admin.go index 42bbb6fc6b..4006a3b743 100644 --- a/gateway/it/steps_envoy_admin.go +++ b/gateway/it/steps_envoy_admin.go @@ -101,18 +101,28 @@ func RegisterEnvoyAdminSteps(ctx *godog.ScenarioContext) { return nil }) + // The set is observed over a settle window rather than once: an update + // propagates to Envoy asynchronously, so a single immediate read could pass + // against the pre-update state and miss a cluster rename that lands moments + // later. Any change inside the window fails immediately. ctx.Step(`^the Envoy cluster names prefixed "([^"]*)" should be unchanged$`, func(prefix string) error { captured, ok := rememberedClusterSets[prefix] if !ok { return fmt.Errorf("no captured cluster set for prefix %q; use the capture step first", prefix) } - current, err := fetchEnvoyClusterNames(prefix) - if err != nil { - return err + deadline := time.Now().Add(6 * time.Second) + for { + current, err := fetchEnvoyClusterNames(prefix) + if err != nil { + return err + } + if strings.Join(captured, ",") != strings.Join(current, ",") { + return fmt.Errorf("Envoy cluster set with prefix %q changed across the update: before=%v after=%v (cluster identity must be stable)", prefix, captured, current) + } + if time.Now().After(deadline) { + return nil + } + time.Sleep(500 * time.Millisecond) } - if strings.Join(captured, ",") != strings.Join(current, ",") { - return fmt.Errorf("Envoy cluster set with prefix %q changed across the update: before=%v after=%v (cluster identity must be stable)", prefix, captured, current) - } - return nil }) } From c72e3a568ebf72ab82238fe7d8f7594e46aa1713 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Mon, 13 Jul 2026 12:22:58 +0530 Subject: [PATCH 3/9] test(gateway): cover sandbox route skip and per-op default upstream --- .../pkg/transform/restapi_test.go | 42 +++++++++++ .../pkg/xds/translator_test.go | 74 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/gateway/gateway-controller/pkg/transform/restapi_test.go b/gateway/gateway-controller/pkg/transform/restapi_test.go index a15d13afb7..a8d2109899 100644 --- a/gateway/gateway-controller/pkg/transform/restapi_test.go +++ b/gateway/gateway-controller/pkg/transform/restapi_test.go @@ -819,6 +819,48 @@ func TestRestAPITransformer_PerOpSandboxInheritsSandboxHostRewrite(t *testing.T) "main route must keep API-level main hostRewrite (auto)") } +// TestRestAPITransformer_PerOpRouteCarriesDefinitionDefault asserts every route's +// Upstream.Default carries its own compiled-in upstream: the referenced definition +// for a per-op route, the API-level main for a plain route, and the API-level +// sandbox for a patched sandbox route. The policy engine reads this field as the +// route's default upstream, so a nil or wrong value breaks no-policy fallbacks. +func TestRestAPITransformer_PerOpRouteCarriesDefinitionDefault(t *testing.T) { + transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeRestAPIWithOps([]api.Operation{ + { + Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Main: opRef("user-svc-cluster"), + }, + }, + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/orders")}, + }) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + perOp := rdc.Routes["GET|/test/users|main.local"] + require.NotNil(t, perOp) + require.NotNil(t, perOp.Upstream.Default, "per-op route must expose a default upstream to the policy engine") + assert.Equal(t, clusterkey.DefinitionName("RestApi", cfg.UUID, "user-svc-cluster"), perOp.Upstream.Default.ClusterName, + "per-op route default must be the referenced definition cluster") + assert.Equal(t, "http://user-svc:8080", perOp.Upstream.Default.URL) + assert.Equal(t, "/", perOp.Upstream.Default.BasePath, + "definition without basePath must default to '/'") + + plain := rdc.Routes["GET|/test/orders|main.local"] + require.NotNil(t, plain) + require.NotNil(t, plain.Upstream.Default) + assert.Equal(t, clusterkey.HashedName("main", cfg.UUID), plain.Upstream.Default.ClusterName, + "plain route default must be the API-level main cluster") + + sandbox := rdc.Routes["GET|/test/users|sandbox.local"] + require.NotNil(t, sandbox) + require.NotNil(t, sandbox.Upstream.Default) + assert.Equal(t, clusterkey.HashedName("sandbox", cfg.UUID), sandbox.Upstream.Default.ClusterName, + "patched sandbox route default must be the API-level sandbox cluster, not main's") +} + // TestResolvePort checks port resolution with explicit, default-http and default-https. func TestResolvePort(t *testing.T) { tests := []struct { diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index de88f25890..a5501d1fa9 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -3423,3 +3423,77 @@ func opRef(ref string) *struct { Ref api.UpstreamReference `json:"ref" yaml:"ref"` }{Ref: ref} } + +// TestTranslateConfigs_PerOpSandboxSkipsOpsWithoutOverride asserts that when sandbox +// is active only through per-op refs (no API-level sandbox upstream), operations +// WITHOUT their own sandbox override get no sandbox-vhost route: routing them there +// would point at a cluster that does not exist. +func TestTranslateConfigs_PerOpSandboxSkipsOpsWithoutOverride(t *testing.T) { + translator := createTestTranslator() + sbVhost := "sandbox.local" + apiData := api.APIConfigData{ + DisplayName: "Test API", + Context: "/test", + Version: "v1.0", + Vhosts: &struct { + Main string `json:"main" yaml:"main"` + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: "localhost", Sandbox: &sbVhost}, + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{Main: api.Upstream{Url: strPtr("http://api-main:8080")}}, + UpstreamDefinitions: &[]api.UpstreamDefinition{ + {Name: "sb-svc", BasePath: strPtr("/sb-svc"), Upstreams: []struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{{Url: "http://sb-svc:8080"}}}, + }, + Operations: []api.Operation{ + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/users"), + Upstream: &api.OperationUpstream{ + Sandbox: opRef("sb-svc"), + }}, + {Method: api.Ptr(api.OperationMethod("GET")), Path: api.Ptr("/orders")}, + }, + } + cfg := &models.StoredConfig{ + UUID: "sandbox-skip-api", + Kind: string(api.RestAPIKindRestApi), + Configuration: api.RestAPI{ + Kind: api.RestAPIKindRestApi, + Metadata: api.Metadata{Name: "sandbox-skip-api"}, + Spec: apiData, + }, + } + + resources, err := translator.TranslateConfigs([]*models.StoredConfig{cfg}, "test-correlation") + require.NoError(t, err) + + var sandboxUsers, sandboxOrders, mainOrders bool + for _, rc := range resources[resource.RouteType] { + for _, vh := range rc.(*route.RouteConfiguration).GetVirtualHosts() { + isSandbox := false + for _, d := range vh.GetDomains() { + if strings.Contains(d, "sandbox.local") { + isSandbox = true + break + } + } + for _, rt := range vh.GetRoutes() { + regex := rt.GetMatch().GetSafeRegex().GetRegex() + switch { + case isSandbox && strings.Contains(regex, "users"): + sandboxUsers = true + case isSandbox && strings.Contains(regex, "orders"): + sandboxOrders = true + case !isSandbox && strings.Contains(regex, "orders"): + mainOrders = true + } + } + } + } + assert.True(t, sandboxUsers, "op with a per-op sandbox ref must get a sandbox-vhost route") + assert.False(t, sandboxOrders, "op without a sandbox override must NOT get a sandbox-vhost route") + assert.True(t, mainOrders, "op without overrides must keep its main-vhost route") +} From afa22f9e149d174a090658dca9ac590e31f6daf4 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Mon, 20 Jul 2026 11:07:52 +0530 Subject: [PATCH 4/9] test(gateway): align the zero connect timeout scenario with the accepted contract The management validator deliberately accepts a zero connect timeout and enforces only the duration format, so deploying a definition with connect 0s returns 201. The scenario previously expected a 400 from the stricter pre-rebase contract and failed once CI ran the suite against the merged main. --- .../features/per-op-upstream-validation.feature | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/gateway/it/features/per-op-upstream-validation.feature b/gateway/it/features/per-op-upstream-validation.feature index 82ea61762d..7f47e2f467 100644 --- a/gateway/it/features/per-op-upstream-validation.feature +++ b/gateway/it/features/per-op-upstream-validation.feature @@ -166,20 +166,23 @@ Feature: Per-Operation Upstream Validation And the JSON response field "status" should be "error" And the response body should contain "must match pattern" - Scenario: Zero connect timeout in an upstreamDefinition is rejected + # The management validator deliberately accepts a zero connect timeout; only the + # duration format is enforced here. Runtime timeout semantics are the translator's + # concern, so deployment must succeed. + Scenario: Zero connect timeout in an upstreamDefinition is accepted Given I authenticate using basic auth as "admin" When I deploy this API configuration: """ apiVersion: gateway.api-platform.wso2.com/v1 kind: RestApi metadata: - name: per-op-val-neg-timeout-api-v1.0 + name: per-op-val-zero-timeout-api-v1.0 spec: - displayName: Per-Op-Val-Neg-Timeout-API + displayName: Per-Op-Val-Zero-Timeout-API version: v1.0 - context: /per-op-val-neg-timeout/$version + context: /per-op-val-zero-timeout/$version vhosts: - main: per-op-val-neg-timeout-main.local + main: per-op-val-zero-timeout-main.local upstreamDefinitions: - name: slow-svc timeout: @@ -196,7 +199,5 @@ Feature: Per-Operation Upstream Validation main: ref: slow-svc """ - Then the response status code should be 400 + Then the response status code should be 201 And the response should be valid JSON - And the JSON response field "status" should be "error" - And the response body should contain "Connect timeout must be a positive duration" From b3ff6be17c755b7239fcf65244c147393327a132 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Wed, 22 Jul 2026 11:56:27 +0530 Subject: [PATCH 5/9] test(gateway): consolidate per-operation upstream IT scenarios into one feature file Merge per-op-upstream-basic, per-op-upstream-ref, per-op-upstream-validation, and api-level-url-stable into a single features/per-op-upstream.feature and register it once in suite_test.go. All 26 scenarios are preserved verbatim; the four files already ran together in the same suite, so this is behavior-neutral. --- .../it/features/api-level-url-stable.feature | 486 ------ .../it/features/per-op-upstream-basic.feature | 474 ------ .../it/features/per-op-upstream-ref.feature | 307 ---- .../per-op-upstream-validation.feature | 203 --- gateway/it/features/per-op-upstream.feature | 1395 +++++++++++++++++ gateway/it/suite_test.go | 5 +- 6 files changed, 1396 insertions(+), 1474 deletions(-) delete mode 100644 gateway/it/features/api-level-url-stable.feature delete mode 100644 gateway/it/features/per-op-upstream-basic.feature delete mode 100644 gateway/it/features/per-op-upstream-ref.feature delete mode 100644 gateway/it/features/per-op-upstream-validation.feature create mode 100644 gateway/it/features/per-op-upstream.feature diff --git a/gateway/it/features/api-level-url-stable.feature b/gateway/it/features/api-level-url-stable.feature deleted file mode 100644 index af7e9c19e5..0000000000 --- a/gateway/it/features/api-level-url-stable.feature +++ /dev/null @@ -1,486 +0,0 @@ -# -------------------------------------------------------------------- -# 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. -# -------------------------------------------------------------------- - -@api-level-url-stable -Feature: API-Level Upstream URL-Stable Cluster Naming - As an API developer - I want API-level main and sandbox cluster names to stay stable across - upstream URL edits - So that routes, name-keyed stats, and cluster identity survive URL changes - and requests keep succeeding during updates - - Background: - Given the gateway services are running - - Scenario: API-level main upstream URL update (host and path change) routes to new backend (URL-stable cluster naming) - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: api-level-url-stable-main-api-v1.0 - spec: - displayName: API-Level-URL-Stable-Main-API - version: v1.0 - context: /api-level-url-stable-main/$version - vhosts: - main: api-level-url-stable-main.local - upstream: - main: - url: http://sample-backend:9080/version-a - operations: - - method: GET - path: /endpoint - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" - - When I clear all headers - And I set request host to "api-level-url-stable-main.local" - And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/version-a/endpoint" - - # Envoy admin: the API-level cluster must use the identity-derived name - # (main_) and there must be no URL-derived (cluster__) - # cluster. The URL-derived form is what the pre-change naming produced, so - # this assertion fails on the old naming scheme. The exact name set is - # captured so the post-update step can prove the NAME survived the update. - When I clear all headers - And I send a GET request to "http://localhost:9901/clusters" - Then the response should be successful - And the response body should contain "main_" - And the response body should not contain "cluster_http_" - And the response body should not contain "cluster_https_" - And I capture the Envoy cluster names prefixed "main_" - - Given I authenticate using basic auth as "admin" - When I update the API "api-level-url-stable-main-api-v1.0" with this configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: api-level-url-stable-main-api-v1.0 - spec: - displayName: API-Level-URL-Stable-Main-API - version: v1.0 - context: /api-level-url-stable-main/$version - vhosts: - main: api-level-url-stable-main.local - upstream: - main: - # The host changes too (container alias of the same backend), proving - # the cluster survives a HOST edit, not only a path edit. The old - # URL-derived naming kept its name across path edits but renamed the - # cluster on any host or scheme change. - url: http://it-sample-backend:9080/version-b - operations: - - method: GET - path: /endpoint - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" - - When I clear all headers - And I set request host to "api-level-url-stable-main.local" - And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/version-b/endpoint" - - # After the HOST change the exact cluster-name set must be UNCHANGED: - # this proves the same main_ cluster survived the host edit (a - # rename to a different main_ would fail the unchanged step). The - # old naming would have minted a new cluster_http_it-sample-backend_9080 - # cluster here and dropped the previous one. - When I clear all headers - And I send a GET request to "http://localhost:9901/clusters" - Then the response should be successful - And the response body should contain "main_" - And the response body should not contain "cluster_http_" - And the response body should not contain "cluster_https_" - And the Envoy cluster names prefixed "main_" should be unchanged - - Given I authenticate using basic auth as "admin" - When I delete the API "api-level-url-stable-main-api-v1.0" - Then the response should be successful - - Scenario: API-level sandbox upstream URL update (host and path change) routes to new backend - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: api-level-url-stable-sandbox-api-v1.0 - spec: - displayName: API-Level-URL-Stable-Sandbox-API - version: v1.0 - context: /api-level-url-stable-sandbox/$version - vhosts: - main: api-level-url-stable-sandbox-main.local - sandbox: api-level-url-stable-sandbox-sb.local - upstream: - main: - url: http://sample-backend:9080/api-main - sandbox: - url: http://sample-backend:9080/sandbox-a - operations: - - method: GET - path: /endpoint - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" - - When I clear all headers - And I set request host to "api-level-url-stable-sandbox-sb.local" - And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/sandbox-a/endpoint" - - # Capture the sandbox cluster-name set so the post-update step can prove - # the sandbox_ name survived the URL update. - When I clear all headers - And I send a GET request to "http://localhost:9901/clusters" - Then the response should be successful - And the response body should contain "sandbox_" - And the response body should not contain "cluster_http_" - And I capture the Envoy cluster names prefixed "sandbox_" - - Given I authenticate using basic auth as "admin" - When I update the API "api-level-url-stable-sandbox-api-v1.0" with this configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: api-level-url-stable-sandbox-api-v1.0 - spec: - displayName: API-Level-URL-Stable-Sandbox-API - version: v1.0 - context: /api-level-url-stable-sandbox/$version - vhosts: - main: api-level-url-stable-sandbox-main.local - sandbox: api-level-url-stable-sandbox-sb.local - upstream: - main: - url: http://sample-backend:9080/api-main - sandbox: - # The sandbox host changes too (container alias of the same - # backend), so this update exercises a host edit on the sandbox - # cluster, not only a path edit. - url: http://it-sample-backend:9080/sandbox-b - operations: - - method: GET - path: /endpoint - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" - - When I clear all headers - And I set request host to "api-level-url-stable-sandbox-sb.local" - And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/sandbox-b/endpoint" - - # Envoy admin: the sandbox cluster must use the identity-derived name - # (sandbox_); no URL-derived cluster may exist, and the exact name - # set must be unchanged across the host edit (identity proof). Fails on - # the old URL-derived naming scheme. - When I clear all headers - And I send a GET request to "http://localhost:9901/clusters" - Then the response should be successful - And the response body should contain "sandbox_" - And the response body should not contain "cluster_http_" - And the response body should not contain "cluster_https_" - And the Envoy cluster names prefixed "sandbox_" should be unchanged - - Given I authenticate using basic auth as "admin" - When I delete the API "api-level-url-stable-sandbox-api-v1.0" - Then the response should be successful - - Scenario: API-level upstream ref resolves to the referenced upstreamDefinitions entry - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: api-level-url-stable-default-api-v1.0 - spec: - displayName: API-Level-URL-Stable-Default-API - version: v1.0 - context: /api-level-url-stable-default/$version - vhosts: - main: api-level-url-stable-default.local - upstreamDefinitions: - - name: backend-default - basePath: /api-main - upstreams: - - url: http://sample-backend:9080 - upstream: - main: - ref: backend-default - operations: - - method: GET - path: /endpoint - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" to be ready with host "api-level-url-stable-default.local" - - When I clear all headers - And I set request host to "api-level-url-stable-default.local" - And I send a GET request to "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/api-main/endpoint" - - Given I authenticate using basic auth as "admin" - When I delete the API "api-level-url-stable-default-api-v1.0" - Then the response should be successful - - Scenario: API-level main and sandbox on the same backend host get separate identity-named clusters - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: api-level-url-stable-collision-api-v1.0 - spec: - displayName: API-Level-URL-Stable-Collision-API - version: v1.0 - context: /api-level-url-stable-collision/$version - vhosts: - main: api-level-url-stable-collision-main.local - sandbox: api-level-url-stable-collision-sb.local - upstream: - main: - url: http://sample-backend:9080/collision-main - sandbox: - url: http://sample-backend:9080/collision-sandbox - operations: - - method: GET - path: /endpoint - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" to be ready with host "api-level-url-stable-collision-main.local" - - # Main and sandbox share the same backend host:port but must route to their - # own base paths. The old URL-derived naming keyed the cluster on host and - # scheme only, so main and sandbox collapsed into one shared cluster here; - # identity naming gives each its own. - When I clear all headers - And I set request host to "api-level-url-stable-collision-main.local" - And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/collision-main/endpoint" - - When I clear all headers - And I set request host to "api-level-url-stable-collision-sb.local" - And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/collision-sandbox/endpoint" - - # Envoy admin: an identity-named main_ and a sandbox_ cluster - # must both exist (they do not collide), and no URL-derived cluster may - # exist. Under the old naming both upstreams shared one cluster__ - # cluster, so this assertion fails on the previous scheme. - When I clear all headers - And I send a GET request to "http://localhost:9901/clusters" - Then the response should be successful - And the response body should contain "main_" - And the response body should contain "sandbox_" - And the response body should not contain "cluster_http_" - And the response body should not contain "cluster_https_" - - Given I authenticate using basic auth as "admin" - When I delete the API "api-level-url-stable-collision-api-v1.0" - Then the response should be successful - - Scenario: Two APIs sharing the same backend host route independently through their own identity-named clusters - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: api-level-url-stable-shared-a-v1.0 - spec: - displayName: API-Level-URL-Stable-Shared-A - version: v1.0 - context: /api-level-url-stable-shared-a/$version - vhosts: - main: api-level-url-stable-shared-a.local - upstream: - main: - url: http://sample-backend:9080/shared-a - operations: - - method: GET - path: /endpoint - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-a.local" - - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: api-level-url-stable-shared-b-v1.0 - spec: - displayName: API-Level-URL-Stable-Shared-B - version: v1.0 - context: /api-level-url-stable-shared-b/$version - vhosts: - main: api-level-url-stable-shared-b.local - upstream: - main: - url: http://sample-backend:9080/shared-b - operations: - - method: GET - path: /endpoint - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-b.local" - - # Two distinct APIs point at the same backend host:port. The old URL-derived - # naming made them share one cluster__ cluster; identity naming - # keys each cluster on its API ID, so the two APIs route independently to their - # own base paths under identity-named clusters and no URL-derived cluster exists. - When I clear all headers - And I set request host to "api-level-url-stable-shared-a.local" - And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/shared-a/endpoint" - - When I clear all headers - And I set request host to "api-level-url-stable-shared-b.local" - And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/shared-b/endpoint" - - When I clear all headers - And I send a GET request to "http://localhost:9901/clusters" - Then the response should be successful - And the response body should contain "main_" - And the response body should not contain "cluster_http_" - And the response body should not contain "cluster_https_" - - # Delete API-B and confirm API-A still routes, proving the two APIs own - # independent clusters (deleting one does not disturb the other). - Given I authenticate using basic auth as "admin" - When I delete the API "api-level-url-stable-shared-b-v1.0" - Then the response should be successful - - When I clear all headers - And I set request host to "api-level-url-stable-shared-a.local" - And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/shared-a/endpoint" - - Given I authenticate using basic auth as "admin" - When I delete the API "api-level-url-stable-shared-a-v1.0" - Then the response should be successful - - Scenario: API-level main upstream scheme and port change keeps the same identity-named cluster - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: api-level-url-stable-scheme-api-v1.0 - spec: - displayName: API-Level-URL-Stable-Scheme-API - version: v1.0 - context: /api-level-url-stable-scheme/$version - vhosts: - main: api-level-url-stable-scheme.local - upstream: - main: - url: http://sample-backend:9080/version-a - operations: - - method: GET - path: /endpoint - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/api-level-url-stable-scheme/v1.0/endpoint" to be ready with host "api-level-url-stable-scheme.local" - - # Capture the identity-derived cluster name while the upstream is plain http. - When I clear all headers - And I send a GET request to "http://localhost:9901/clusters" - Then the response should be successful - And the response body should contain "main_" - And the response body should not contain "cluster_http_" - And the response body should not contain "cluster_https_" - And I capture the Envoy cluster names prefixed "main_" - - # Change the upstream scheme (http -> https) AND port (9080 -> 9443) in one - # edit. The old URL-derived naming embedded scheme and port in the cluster - # name (cluster___), so this edit would have minted a new - # cluster_https_ cluster and dropped the previous one. Identity-based naming - # must keep the SAME main_ and never produce a cluster_https_. TLS - # routing itself is not asserted (there is no TLS echo backend), so there is no - # endpoint-readiness wait here; the cluster-set check below observes a settle - # window instead to cover xDS propagation. The cluster - # name is stable independent of upstream reachability. - Given I authenticate using basic auth as "admin" - When I update the API "api-level-url-stable-scheme-api-v1.0" with this configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: api-level-url-stable-scheme-api-v1.0 - spec: - displayName: API-Level-URL-Stable-Scheme-API - version: v1.0 - context: /api-level-url-stable-scheme/$version - vhosts: - main: api-level-url-stable-scheme.local - upstream: - main: - url: https://sample-backend:9443/version-b - operations: - - method: GET - path: /endpoint - """ - Then the response should be successful - - # The main_ name set must be UNCHANGED after the scheme/port edit, and - # no URL-derived cluster_https_ may appear. - When I clear all headers - And I send a GET request to "http://localhost:9901/clusters" - Then the response should be successful - And the response body should contain "main_" - And the response body should not contain "cluster_http_" - And the response body should not contain "cluster_https_" - And the Envoy cluster names prefixed "main_" should be unchanged - - Given I authenticate using basic auth as "admin" - When I delete the API "api-level-url-stable-scheme-api-v1.0" - Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-basic.feature b/gateway/it/features/per-op-upstream-basic.feature deleted file mode 100644 index 6cb7b8b1c7..0000000000 --- a/gateway/it/features/per-op-upstream-basic.feature +++ /dev/null @@ -1,474 +0,0 @@ -# -------------------------------------------------------------------- -# 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. -# -------------------------------------------------------------------- - -@per-op-upstream-basic -Feature: Per-Operation Upstream Basic Routing - As an API developer - I want per-operation upstream refs to override API-level upstreams - So that different operations can route to different backends - - Background: - Given the gateway services are running - - Scenario: API-level main fallback when operation has no per-op upstream - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-basic-fm-api-v1.0 - spec: - displayName: Per-Op-Basic-FM-API - version: v1.0 - context: /per-op-basic-fm/$version - vhosts: - main: per-op-basic-fm-main.local - sandbox: per-op-basic-fm-sandbox.local - upstream: - main: - url: http://sample-backend:9080/api-main - sandbox: - url: http://sample-backend:9080/api-sandbox - operations: - - method: GET - path: /users - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-basic-fm/v1.0/users" to be ready with host "per-op-basic-fm-main.local" - - When I clear all headers - And I set request host to "per-op-basic-fm-main.local" - And I send a GET request to "http://localhost:8080/per-op-basic-fm/v1.0/users" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/api-main/users" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-basic-fm-api-v1.0" - Then the response should be successful - - Scenario: API-level sandbox fallback when operation has no per-op upstream - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-basic-fs-api-v1.0 - spec: - displayName: Per-Op-Basic-FS-API - version: v1.0 - context: /per-op-basic-fs/$version - vhosts: - main: per-op-basic-fs-main.local - sandbox: per-op-basic-fs-sandbox.local - upstream: - main: - url: http://sample-backend:9080/api-main - sandbox: - url: http://sample-backend:9080/api-sandbox - operations: - - method: GET - path: /users - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-basic-fs/v1.0/users" to be ready with host "per-op-basic-fs-sandbox.local" - - When I clear all headers - And I set request host to "per-op-basic-fs-sandbox.local" - And I send a GET request to "http://localhost:8080/per-op-basic-fs/v1.0/users" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/api-sandbox/users" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-basic-fs-api-v1.0" - Then the response should be successful - - Scenario: Per-operation main ref overrides API-level main - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-basic-om-api-v1.0 - spec: - displayName: Per-Op-Basic-OM-API - version: v1.0 - context: /per-op-basic-om/$version - vhosts: - main: per-op-basic-om-main.local - upstreamDefinitions: - - name: op-main-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /op-main - upstream: - main: - url: http://sample-backend:9080/api-main - operations: - - method: GET - path: /users - upstream: - main: - ref: op-main-svc - - method: GET - path: /orders - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-basic-om/v1.0/users" to be ready with host "per-op-basic-om-main.local" - - When I clear all headers - And I set request host to "per-op-basic-om-main.local" - And I send a GET request to "http://localhost:8080/per-op-basic-om/v1.0/users" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/op-main/users" - - When I clear all headers - And I set request host to "per-op-basic-om-main.local" - And I send a GET request to "http://localhost:8080/per-op-basic-om/v1.0/orders" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/api-main/orders" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-basic-om-api-v1.0" - Then the response should be successful - - Scenario: Per-operation sandbox-only override routes sandbox traffic to the operation upstream - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-basic-os-api-v1.0 - spec: - displayName: Per-Op-Basic-OS-API - version: v1.0 - context: /per-op-basic-os/$version - vhosts: - main: per-op-basic-os-main.local - sandbox: per-op-basic-os-sandbox.local - upstreamDefinitions: - - name: op-sandbox-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /op-sandbox - upstream: - main: - url: http://sample-backend:9080/api-main - operations: - - method: GET - path: /users - upstream: - sandbox: - ref: op-sandbox-svc - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-basic-os/v1.0/users" to be ready with host "per-op-basic-os-sandbox.local" - - When I clear all headers - And I set request host to "per-op-basic-os-sandbox.local" - And I send a GET request to "http://localhost:8080/per-op-basic-os/v1.0/users" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/op-sandbox/users" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-basic-os-api-v1.0" - Then the response should be successful - - Scenario: Sandbox falls back when operation only has per-op main - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-basic-sf-api-v1.0 - spec: - displayName: Per-Op-Basic-SF-API - version: v1.0 - context: /per-op-basic-sf/$version - vhosts: - main: per-op-basic-sf-main.local - sandbox: per-op-basic-sf-sandbox.local - upstreamDefinitions: - - name: op-main-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /op-main - upstream: - main: - url: http://sample-backend:9080/api-main - sandbox: - url: http://sample-backend:9080/api-sandbox - operations: - - method: GET - path: /users - upstream: - main: - ref: op-main-svc - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-basic-sf/v1.0/users" to be ready with host "per-op-basic-sf-sandbox.local" - - When I clear all headers - And I set request host to "per-op-basic-sf-sandbox.local" - And I send a GET request to "http://localhost:8080/per-op-basic-sf/v1.0/users" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/api-sandbox/users" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-basic-sf-api-v1.0" - Then the response should be successful - - Scenario: Main falls back when operation only has per-op sandbox - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-basic-mf-api-v1.0 - spec: - displayName: Per-Op-Basic-MF-API - version: v1.0 - context: /per-op-basic-mf/$version - vhosts: - main: per-op-basic-mf-main.local - sandbox: per-op-basic-mf-sandbox.local - upstreamDefinitions: - - name: op-sandbox-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /op-sandbox - upstream: - main: - url: http://sample-backend:9080/api-main - sandbox: - url: http://sample-backend:9080/api-sandbox - operations: - - method: GET - path: /users - upstream: - sandbox: - ref: op-sandbox-svc - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-basic-mf/v1.0/users" to be ready with host "per-op-basic-mf-main.local" - - When I clear all headers - And I set request host to "per-op-basic-mf-main.local" - And I send a GET request to "http://localhost:8080/per-op-basic-mf/v1.0/users" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/api-main/users" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-basic-mf-api-v1.0" - Then the response should be successful - - Scenario: Operation with both per-op main and sandbox overrides - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-basic-both-api-v1.0 - spec: - displayName: Per-Op-Basic-Both-API - version: v1.0 - context: /per-op-basic-both/$version - vhosts: - main: per-op-basic-both-main.local - sandbox: per-op-basic-both-sandbox.local - upstreamDefinitions: - - name: op-main-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /op-main - - name: op-sandbox-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /op-sandbox - upstream: - main: - url: http://sample-backend:9080/api-main - sandbox: - url: http://sample-backend:9080/api-sandbox - operations: - - method: GET - path: /users - upstream: - main: - ref: op-main-svc - sandbox: - ref: op-sandbox-svc - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-basic-both/v1.0/users" to be ready with host "per-op-basic-both-main.local" - - When I clear all headers - And I set request host to "per-op-basic-both-main.local" - And I send a GET request to "http://localhost:8080/per-op-basic-both/v1.0/users" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/op-main/users" - - When I clear all headers - And I set request host to "per-op-basic-both-sandbox.local" - And I send a GET request to "http://localhost:8080/per-op-basic-both/v1.0/users" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/op-sandbox/users" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-basic-both-api-v1.0" - Then the response should be successful - - Scenario: Per-operation upstream definition basePath update routes to the new path - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-basic-eds-api-v1.0 - spec: - displayName: Per-Op-Basic-EDS-API - version: v1.0 - context: /per-op-basic-eds/$version - vhosts: - main: per-op-basic-eds-main.local - upstreamDefinitions: - - name: op-versioned-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /version-a - upstream: - main: - url: http://sample-backend:9080/api-main - operations: - - method: GET - path: /endpoint - upstream: - main: - ref: op-versioned-svc - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" to be ready with host "per-op-basic-eds-main.local" - - When I clear all headers - And I set request host to "per-op-basic-eds-main.local" - And I send a GET request to "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/version-a/endpoint" - - Given I authenticate using basic auth as "admin" - When I update the API "per-op-basic-eds-api-v1.0" with this configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-basic-eds-api-v1.0 - spec: - displayName: Per-Op-Basic-EDS-API - version: v1.0 - context: /per-op-basic-eds/$version - vhosts: - main: per-op-basic-eds-main.local - upstreamDefinitions: - - name: op-versioned-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /version-b - upstream: - main: - url: http://sample-backend:9080/api-main - operations: - - method: GET - path: /endpoint - upstream: - main: - ref: op-versioned-svc - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" to be ready with host "per-op-basic-eds-main.local" - - When I clear all headers - And I set request host to "per-op-basic-eds-main.local" - And I send a GET request to "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/version-b/endpoint" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-basic-eds-api-v1.0" - Then the response should be successful - - Scenario: Per-op sandbox inherits API-level main hostRewrite when no API-level sandbox - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-basic-sbhr-api-v1.0 - spec: - displayName: Per-Op-Basic-SBHR-API - version: v1.0 - context: /per-op-basic-sbhr/$version - vhosts: - main: per-op-basic-sbhr-main.local - sandbox: per-op-basic-sbhr-sandbox.local - upstreamDefinitions: - - name: op-sandbox-svc - upstreams: - - url: http://echo-backend:80 - basePath: /anything - upstream: - main: - url: http://echo-backend:80/anything - hostRewrite: manual - operations: - - method: GET - path: /test - upstream: - sandbox: - ref: op-sandbox-svc - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-basic-sbhr/v1.0/test" to be ready with host "per-op-basic-sbhr-sandbox.local" - - When I clear all headers - And I set request host to "per-op-basic-sbhr-sandbox.local" - And I send a GET request to "http://localhost:8080/per-op-basic-sbhr/v1.0/test" - Then the response status code should be 200 - And the JSON response field "headers.Host" should be "per-op-basic-sbhr-sandbox.local" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-basic-sbhr-api-v1.0" - Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-ref.feature b/gateway/it/features/per-op-upstream-ref.feature deleted file mode 100644 index 666d9f1aff..0000000000 --- a/gateway/it/features/per-op-upstream-ref.feature +++ /dev/null @@ -1,307 +0,0 @@ -# -------------------------------------------------------------------- -# 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. -# -------------------------------------------------------------------- - -@per-op-upstream-ref -Feature: Per-Operation Upstream Ref - As an API developer - I want per-operation upstream refs to resolve through upstreamDefinitions - So that different operations can route to different backends - - Background: - Given the gateway services are running - - Scenario: Per-operation main refs route to different backend services on different ports - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-ref-api-v1.0 - spec: - displayName: Per-Op-Ref-API - version: v1.0 - context: /per-op/$version - vhosts: - main: per-op-main.local - upstreamDefinitions: - - name: users-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /user-svc - - name: orders-svc - upstreams: - - url: http://echo-backend:80 - basePath: /anything - upstream: - main: - url: http://sample-backend:9080 - operations: - - method: GET - path: /users - upstream: - main: - ref: users-svc - - method: GET - path: /orders - upstream: - main: - ref: orders-svc - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op/v1.0/users" to be ready with host "per-op-main.local" - And I wait for the endpoint "http://localhost:8080/per-op/v1.0/orders" to be ready with host "per-op-main.local" - - When I clear all headers - And I set request host to "per-op-main.local" - And I send a GET request to "http://localhost:8080/per-op/v1.0/users" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/user-svc/users" - - When I clear all headers - And I set request host to "per-op-main.local" - And I send a GET request to "http://localhost:8080/per-op/v1.0/orders" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "url" should be "http://echo-backend/anything/orders" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-ref-api-v1.0" - Then the response should be successful - - Scenario: Mixed operations - one with per-op ref, one falling back to API-level - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-mixed-api-v1.0 - spec: - displayName: Per-Op-Mixed-API - version: v1.0 - context: /per-op-mixed/$version - vhosts: - main: per-op-mixed-main.local - upstreamDefinitions: - - name: users-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /user-svc - upstream: - main: - url: http://sample-backend:9080/api-main - operations: - - method: GET - path: /users - upstream: - main: - ref: users-svc - - method: GET - path: /orders - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-mixed/v1.0/users" to be ready with host "per-op-mixed-main.local" - - When I clear all headers - And I set request host to "per-op-mixed-main.local" - And I send a GET request to "http://localhost:8080/per-op-mixed/v1.0/users" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/user-svc/users" - - When I clear all headers - And I set request host to "per-op-mixed-main.local" - And I send a GET request to "http://localhost:8080/per-op-mixed/v1.0/orders" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/api-main/orders" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-mixed-api-v1.0" - Then the response should be successful - - Scenario: Operation-level dynamic-endpoint policy overrides the per-op ref - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-prec-op-api-v1.0 - spec: - displayName: Per-Op-Prec-Op-API - version: v1.0 - context: /per-op-prec-op/$version - vhosts: - main: per-op-prec-op-main.local - upstreamDefinitions: - - name: ref-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /ref-svc - - name: op-policy-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /op-policy-svc - upstream: - main: - url: http://sample-backend:9080/api-main - operations: - - method: GET - path: /override - upstream: - main: - ref: ref-svc - policies: - - name: dynamic-endpoint - version: v1 - params: - targetUpstream: op-policy-svc - - method: GET - path: /fallback - upstream: - main: - ref: ref-svc - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-prec-op/v1.0/fallback" to be ready with host "per-op-prec-op-main.local" - - # Operation-level dynamic-endpoint policy wins over the per-op ref. - When I clear all headers - And I set request host to "per-op-prec-op-main.local" - And I send a GET request to "http://localhost:8080/per-op-prec-op/v1.0/override" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/op-policy-svc/override" - - # No policy on this op: the per-op ref is the default. - When I clear all headers - And I set request host to "per-op-prec-op-main.local" - And I send a GET request to "http://localhost:8080/per-op-prec-op/v1.0/fallback" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/ref-svc/fallback" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-prec-op-api-v1.0" - Then the response should be successful - - Scenario: API-level dynamic-endpoint policy overrides the per-op ref - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-prec-api-api-v1.0 - spec: - displayName: Per-Op-Prec-Api-API - version: v1.0 - context: /per-op-prec-api/$version - vhosts: - main: per-op-prec-api-main.local - upstreamDefinitions: - - name: ref-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /ref-svc - - name: global-policy-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /global-policy-svc - upstream: - main: - url: http://sample-backend:9080/api-main - policies: - - name: dynamic-endpoint - version: v1 - params: - targetUpstream: global-policy-svc - operations: - - method: GET - path: /items - upstream: - main: - ref: ref-svc - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-prec-api/v1.0/items" to be ready with host "per-op-prec-api-main.local" - - # API-level dynamic-endpoint policy wins over the per-op ref (dynamic beats static upstream). - When I clear all headers - And I set request host to "per-op-prec-api-main.local" - And I send a GET request to "http://localhost:8080/per-op-prec-api/v1.0/items" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/global-policy-svc/items" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-prec-api-api-v1.0" - Then the response should be successful - - Scenario: Request-rewrite policy composes with a per-op ref - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-ref-rewrite-api-v1.0 - spec: - displayName: Per-Op-Ref-Rewrite-API - version: v1.0 - context: /per-op-ref-rewrite/$version - vhosts: - main: per-op-ref-rewrite-main.local - upstreamDefinitions: - - name: ref-svc - upstreams: - - url: http://sample-backend:9080 - basePath: /ref-svc - upstream: - main: - url: http://sample-backend:9080 - operations: - - method: GET - path: /whoami - upstream: - main: - ref: ref-svc - policies: - - name: request-rewrite - version: v1 - params: - pathRewrite: - type: ReplaceFullPath - replaceFullPath: /rewritten - """ - Then the response should be successful - And I wait for the endpoint "http://localhost:8080/per-op-ref-rewrite/v1.0/whoami" to be ready with host "per-op-ref-rewrite-main.local" - - When I clear all headers - And I set request host to "per-op-ref-rewrite-main.local" - And I send a GET request to "http://localhost:8080/per-op-ref-rewrite/v1.0/whoami" - Then the response should be successful - And the response should be valid JSON - And the JSON response field "path" should be "/ref-svc/rewritten" - - Given I authenticate using basic auth as "admin" - When I delete the API "per-op-ref-rewrite-api-v1.0" - Then the response should be successful diff --git a/gateway/it/features/per-op-upstream-validation.feature b/gateway/it/features/per-op-upstream-validation.feature deleted file mode 100644 index 7f47e2f467..0000000000 --- a/gateway/it/features/per-op-upstream-validation.feature +++ /dev/null @@ -1,203 +0,0 @@ -# -------------------------------------------------------------------- -# 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. -# -------------------------------------------------------------------- - -@per-op-upstream-validation -Feature: Per-Operation Upstream Validation - As an API developer - I want malformed per-operation upstream configurations to be rejected - So that invalid APIs cannot be deployed - - Background: - Given the gateway services are running - - Scenario: Empty per-op upstream wrapper is rejected - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-val-empty-api-v1.0 - spec: - displayName: Per-Op-Val-Empty-API - version: v1.0 - context: /per-op-val-empty/$version - vhosts: - main: per-op-val-empty-main.local - upstream: - main: - url: http://sample-backend:9080 - operations: - - method: GET - path: /users - upstream: {} - """ - Then the response status code should be 400 - And the response should be valid JSON - And the JSON response field "status" should be "error" - And the response body should contain "At least one of 'main' or 'sandbox' must be set" - - Scenario: Per-op ref to non-existent upstream definition is rejected - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-val-missing-ref-api-v1.0 - spec: - displayName: Per-Op-Val-Missing-Ref-API - version: v1.0 - context: /per-op-val-missing-ref/$version - vhosts: - main: per-op-val-missing-ref-main.local - upstream: - main: - url: http://sample-backend:9080 - operations: - - method: GET - path: /users - upstream: - main: - ref: does-not-exist - """ - Then the response status code should be 400 - And the response should be valid JSON - And the JSON response field "status" should be "error" - And the response body should contain "Referenced upstream definition 'does-not-exist' not found" - - Scenario: Empty per-op leaf is rejected - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-val-empty-leaf-api-v1.0 - spec: - displayName: Per-Op-Val-Empty-Leaf-API - version: v1.0 - context: /per-op-val-empty-leaf/$version - vhosts: - main: per-op-val-empty-leaf-main.local - upstream: - main: - url: http://sample-backend:9080 - operations: - - method: GET - path: /users - upstream: - main: {} - """ - Then the response status code should be 400 - And the response should be valid JSON - And the JSON response field "status" should be "error" - And the response body should contain "Upstream ref is required" - - Scenario: Empty per-op sandbox leaf with no API-level sandbox is rejected - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-val-empty-sandbox-api-v1.0 - spec: - displayName: Per-Op-Val-Empty-Sandbox-API - version: v1.0 - context: /per-op-val-empty-sandbox/$version - vhosts: - main: per-op-val-empty-sandbox-main.local - upstream: - main: - url: http://sample-backend:9080 - operations: - - method: GET - path: /users - upstream: - sandbox: {} - """ - Then the response status code should be 400 - And the response should be valid JSON - And the JSON response field "status" should be "error" - And the response body should contain "Upstream ref is required" - - Scenario: Per-op ref with invalid characters is rejected - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-val-bad-ref-api-v1.0 - spec: - displayName: Per-Op-Val-Bad-Ref-API - version: v1.0 - context: /per-op-val-bad-ref/$version - vhosts: - main: per-op-val-bad-ref-main.local - upstream: - main: - url: http://sample-backend:9080 - operations: - - method: GET - path: /users - upstream: - main: - ref: "bad/ref!" - """ - Then the response status code should be 400 - And the response should be valid JSON - And the JSON response field "status" should be "error" - And the response body should contain "must match pattern" - - # The management validator deliberately accepts a zero connect timeout; only the - # duration format is enforced here. Runtime timeout semantics are the translator's - # concern, so deployment must succeed. - Scenario: Zero connect timeout in an upstreamDefinition is accepted - Given I authenticate using basic auth as "admin" - When I deploy this API configuration: - """ - apiVersion: gateway.api-platform.wso2.com/v1 - kind: RestApi - metadata: - name: per-op-val-zero-timeout-api-v1.0 - spec: - displayName: Per-Op-Val-Zero-Timeout-API - version: v1.0 - context: /per-op-val-zero-timeout/$version - vhosts: - main: per-op-val-zero-timeout-main.local - upstreamDefinitions: - - name: slow-svc - timeout: - connect: 0s - upstreams: - - url: http://sample-backend:9080 - upstream: - main: - url: http://sample-backend:9080 - operations: - - method: GET - path: /users - upstream: - main: - ref: slow-svc - """ - Then the response status code should be 201 - And the response should be valid JSON diff --git a/gateway/it/features/per-op-upstream.feature b/gateway/it/features/per-op-upstream.feature new file mode 100644 index 0000000000..91f7b1f52a --- /dev/null +++ b/gateway/it/features/per-op-upstream.feature @@ -0,0 +1,1395 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +@per-op-upstream +Feature: Per-Operation Upstream + As an API developer + I want per-operation upstream refs to override the API-level upstream, with API-level + URL edits staying cluster-stable + So that different operations can route to different backends without disruptive redeploys + + Background: + Given the gateway services are running + + # ===== from per-op-upstream-basic.feature ===== + Scenario: API-level main fallback when operation has no per-op upstream + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-fm-api-v1.0 + spec: + displayName: Per-Op-Basic-FM-API + version: v1.0 + context: /per-op-basic-fm/$version + vhosts: + main: per-op-basic-fm-main.local + sandbox: per-op-basic-fm-sandbox.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-fm/v1.0/users" to be ready with host "per-op-basic-fm-main.local" + + When I clear all headers + And I set request host to "per-op-basic-fm-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-fm/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-fm-api-v1.0" + Then the response should be successful + + Scenario: API-level sandbox fallback when operation has no per-op upstream + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-fs-api-v1.0 + spec: + displayName: Per-Op-Basic-FS-API + version: v1.0 + context: /per-op-basic-fs/$version + vhosts: + main: per-op-basic-fs-main.local + sandbox: per-op-basic-fs-sandbox.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-fs/v1.0/users" to be ready with host "per-op-basic-fs-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-fs-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-fs/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-fs-api-v1.0" + Then the response should be successful + + Scenario: Per-operation main ref overrides API-level main + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-om-api-v1.0 + spec: + displayName: Per-Op-Basic-OM-API + version: v1.0 + context: /per-op-basic-om/$version + vhosts: + main: per-op-basic-om-main.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + - method: GET + path: /orders + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-om/v1.0/users" to be ready with host "per-op-basic-om-main.local" + + When I clear all headers + And I set request host to "per-op-basic-om-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-om/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-main/users" + + When I clear all headers + And I set request host to "per-op-basic-om-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-om/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-om-api-v1.0" + Then the response should be successful + + Scenario: Per-operation sandbox-only override routes sandbox traffic to the operation upstream + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-os-api-v1.0 + spec: + displayName: Per-Op-Basic-OS-API + version: v1.0 + context: /per-op-basic-os/$version + vhosts: + main: per-op-basic-os-main.local + sandbox: per-op-basic-os-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-os/v1.0/users" to be ready with host "per-op-basic-os-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-os-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-os/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-os-api-v1.0" + Then the response should be successful + + Scenario: Sandbox falls back when operation only has per-op main + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-sf-api-v1.0 + spec: + displayName: Per-Op-Basic-SF-API + version: v1.0 + context: /per-op-basic-sf/$version + vhosts: + main: per-op-basic-sf-main.local + sandbox: per-op-basic-sf-sandbox.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-sf/v1.0/users" to be ready with host "per-op-basic-sf-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-sf-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-sf/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-sf-api-v1.0" + Then the response should be successful + + Scenario: Main falls back when operation only has per-op sandbox + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-mf-api-v1.0 + spec: + displayName: Per-Op-Basic-MF-API + version: v1.0 + context: /per-op-basic-mf/$version + vhosts: + main: per-op-basic-mf-main.local + sandbox: per-op-basic-mf-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-mf/v1.0/users" to be ready with host "per-op-basic-mf-main.local" + + When I clear all headers + And I set request host to "per-op-basic-mf-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-mf/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-mf-api-v1.0" + Then the response should be successful + + Scenario: Operation with both per-op main and sandbox overrides + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-both-api-v1.0 + spec: + displayName: Per-Op-Basic-Both-API + version: v1.0 + context: /per-op-basic-both/$version + vhosts: + main: per-op-basic-both-main.local + sandbox: per-op-basic-both-sandbox.local + upstreamDefinitions: + - name: op-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-main + - name: op-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/api-sandbox + operations: + - method: GET + path: /users + upstream: + main: + ref: op-main-svc + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-both/v1.0/users" to be ready with host "per-op-basic-both-main.local" + + When I clear all headers + And I set request host to "per-op-basic-both-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-both/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-main/users" + + When I clear all headers + And I set request host to "per-op-basic-both-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-both/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-both-api-v1.0" + Then the response should be successful + + Scenario: Per-operation upstream definition basePath update routes to the new path + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-eds-api-v1.0 + spec: + displayName: Per-Op-Basic-EDS-API + version: v1.0 + context: /per-op-basic-eds/$version + vhosts: + main: per-op-basic-eds-main.local + upstreamDefinitions: + - name: op-versioned-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /version-a + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /endpoint + upstream: + main: + ref: op-versioned-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" to be ready with host "per-op-basic-eds-main.local" + + When I clear all headers + And I set request host to "per-op-basic-eds-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-a/endpoint" + + Given I authenticate using basic auth as "admin" + When I update the API "per-op-basic-eds-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-eds-api-v1.0 + spec: + displayName: Per-Op-Basic-EDS-API + version: v1.0 + context: /per-op-basic-eds/$version + vhosts: + main: per-op-basic-eds-main.local + upstreamDefinitions: + - name: op-versioned-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /version-b + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /endpoint + upstream: + main: + ref: op-versioned-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" to be ready with host "per-op-basic-eds-main.local" + + When I clear all headers + And I set request host to "per-op-basic-eds-main.local" + And I send a GET request to "http://localhost:8080/per-op-basic-eds/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-b/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-eds-api-v1.0" + Then the response should be successful + + Scenario: Per-op sandbox inherits API-level main hostRewrite when no API-level sandbox + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-basic-sbhr-api-v1.0 + spec: + displayName: Per-Op-Basic-SBHR-API + version: v1.0 + context: /per-op-basic-sbhr/$version + vhosts: + main: per-op-basic-sbhr-main.local + sandbox: per-op-basic-sbhr-sandbox.local + upstreamDefinitions: + - name: op-sandbox-svc + upstreams: + - url: http://echo-backend:80 + basePath: /anything + upstream: + main: + url: http://echo-backend:80/anything + hostRewrite: manual + operations: + - method: GET + path: /test + upstream: + sandbox: + ref: op-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-basic-sbhr/v1.0/test" to be ready with host "per-op-basic-sbhr-sandbox.local" + + When I clear all headers + And I set request host to "per-op-basic-sbhr-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-basic-sbhr/v1.0/test" + Then the response status code should be 200 + And the JSON response field "headers.Host" should be "per-op-basic-sbhr-sandbox.local" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-basic-sbhr-api-v1.0" + Then the response should be successful + + # ===== from per-op-upstream-ref.feature ===== + Scenario: Per-operation main refs route to different backend services on different ports + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-ref-api-v1.0 + spec: + displayName: Per-Op-Ref-API + version: v1.0 + context: /per-op/$version + vhosts: + main: per-op-main.local + upstreamDefinitions: + - name: users-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /user-svc + - name: orders-svc + upstreams: + - url: http://echo-backend:80 + basePath: /anything + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: users-svc + - method: GET + path: /orders + upstream: + main: + ref: orders-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op/v1.0/users" to be ready with host "per-op-main.local" + And I wait for the endpoint "http://localhost:8080/per-op/v1.0/orders" to be ready with host "per-op-main.local" + + When I clear all headers + And I set request host to "per-op-main.local" + And I send a GET request to "http://localhost:8080/per-op/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/user-svc/users" + + When I clear all headers + And I set request host to "per-op-main.local" + And I send a GET request to "http://localhost:8080/per-op/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "url" should be "http://echo-backend/anything/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-ref-api-v1.0" + Then the response should be successful + + Scenario: Mixed operations - one with per-op ref, one falling back to API-level + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-mixed-api-v1.0 + spec: + displayName: Per-Op-Mixed-API + version: v1.0 + context: /per-op-mixed/$version + vhosts: + main: per-op-mixed-main.local + upstreamDefinitions: + - name: users-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /user-svc + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /users + upstream: + main: + ref: users-svc + - method: GET + path: /orders + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-mixed/v1.0/users" to be ready with host "per-op-mixed-main.local" + + When I clear all headers + And I set request host to "per-op-mixed-main.local" + And I send a GET request to "http://localhost:8080/per-op-mixed/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/user-svc/users" + + When I clear all headers + And I set request host to "per-op-mixed-main.local" + And I send a GET request to "http://localhost:8080/per-op-mixed/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-mixed-api-v1.0" + Then the response should be successful + + Scenario: Operation-level dynamic-endpoint policy overrides the per-op ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-prec-op-api-v1.0 + spec: + displayName: Per-Op-Prec-Op-API + version: v1.0 + context: /per-op-prec-op/$version + vhosts: + main: per-op-prec-op-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + - name: op-policy-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /op-policy-svc + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - method: GET + path: /override + upstream: + main: + ref: ref-svc + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: op-policy-svc + - method: GET + path: /fallback + upstream: + main: + ref: ref-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-prec-op/v1.0/fallback" to be ready with host "per-op-prec-op-main.local" + + # Operation-level dynamic-endpoint policy wins over the per-op ref. + When I clear all headers + And I set request host to "per-op-prec-op-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-op/v1.0/override" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/op-policy-svc/override" + + # No policy on this op: the per-op ref is the default. + When I clear all headers + And I set request host to "per-op-prec-op-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-op/v1.0/fallback" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/ref-svc/fallback" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-prec-op-api-v1.0" + Then the response should be successful + + Scenario: API-level dynamic-endpoint policy overrides the per-op ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-prec-api-api-v1.0 + spec: + displayName: Per-Op-Prec-Api-API + version: v1.0 + context: /per-op-prec-api/$version + vhosts: + main: per-op-prec-api-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + - name: global-policy-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /global-policy-svc + upstream: + main: + url: http://sample-backend:9080/api-main + policies: + - name: dynamic-endpoint + version: v1 + params: + targetUpstream: global-policy-svc + operations: + - method: GET + path: /items + upstream: + main: + ref: ref-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-prec-api/v1.0/items" to be ready with host "per-op-prec-api-main.local" + + # API-level dynamic-endpoint policy wins over the per-op ref (dynamic beats static upstream). + When I clear all headers + And I set request host to "per-op-prec-api-main.local" + And I send a GET request to "http://localhost:8080/per-op-prec-api/v1.0/items" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/global-policy-svc/items" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-prec-api-api-v1.0" + Then the response should be successful + + Scenario: Request-rewrite policy composes with a per-op ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-ref-rewrite-api-v1.0 + spec: + displayName: Per-Op-Ref-Rewrite-API + version: v1.0 + context: /per-op-ref-rewrite/$version + vhosts: + main: per-op-ref-rewrite-main.local + upstreamDefinitions: + - name: ref-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /ref-svc + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /whoami + upstream: + main: + ref: ref-svc + policies: + - name: request-rewrite + version: v1 + params: + pathRewrite: + type: ReplaceFullPath + replaceFullPath: /rewritten + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-ref-rewrite/v1.0/whoami" to be ready with host "per-op-ref-rewrite-main.local" + + When I clear all headers + And I set request host to "per-op-ref-rewrite-main.local" + And I send a GET request to "http://localhost:8080/per-op-ref-rewrite/v1.0/whoami" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/ref-svc/rewritten" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-ref-rewrite-api-v1.0" + Then the response should be successful + + # ===== from per-op-upstream-validation.feature ===== + Scenario: Empty per-op upstream wrapper is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-empty-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-API + version: v1.0 + context: /per-op-val-empty/$version + vhosts: + main: per-op-val-empty-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: {} + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "At least one of 'main' or 'sandbox' must be set" + + Scenario: Per-op ref to non-existent upstream definition is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-missing-ref-api-v1.0 + spec: + displayName: Per-Op-Val-Missing-Ref-API + version: v1.0 + context: /per-op-val-missing-ref/$version + vhosts: + main: per-op-val-missing-ref-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: does-not-exist + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Referenced upstream definition 'does-not-exist' not found" + + Scenario: Empty per-op leaf is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-empty-leaf-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-Leaf-API + version: v1.0 + context: /per-op-val-empty-leaf/$version + vhosts: + main: per-op-val-empty-leaf-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: {} + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Upstream ref is required" + + Scenario: Empty per-op sandbox leaf with no API-level sandbox is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-empty-sandbox-api-v1.0 + spec: + displayName: Per-Op-Val-Empty-Sandbox-API + version: v1.0 + context: /per-op-val-empty-sandbox/$version + vhosts: + main: per-op-val-empty-sandbox-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + sandbox: {} + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Upstream ref is required" + + Scenario: Per-op ref with invalid characters is rejected + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-bad-ref-api-v1.0 + spec: + displayName: Per-Op-Val-Bad-Ref-API + version: v1.0 + context: /per-op-val-bad-ref/$version + vhosts: + main: per-op-val-bad-ref-main.local + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: "bad/ref!" + """ + Then the response status code should be 400 + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "must match pattern" + + # The management validator deliberately accepts a zero connect timeout; only the + # duration format is enforced here. Runtime timeout semantics are the translator's + # concern, so deployment must succeed. + Scenario: Zero connect timeout in an upstreamDefinition is accepted + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-val-zero-timeout-api-v1.0 + spec: + displayName: Per-Op-Val-Zero-Timeout-API + version: v1.0 + context: /per-op-val-zero-timeout/$version + vhosts: + main: per-op-val-zero-timeout-main.local + upstreamDefinitions: + - name: slow-svc + timeout: + connect: 0s + upstreams: + - url: http://sample-backend:9080 + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /users + upstream: + main: + ref: slow-svc + """ + Then the response status code should be 201 + And the response should be valid JSON + + # ===== from api-level-url-stable.feature ===== + Scenario: API-level main upstream URL update (host and path change) routes to new backend (URL-stable cluster naming) + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-main-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Main-API + version: v1.0 + context: /api-level-url-stable-main/$version + vhosts: + main: api-level-url-stable-main.local + upstream: + main: + url: http://sample-backend:9080/version-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" + + When I clear all headers + And I set request host to "api-level-url-stable-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-a/endpoint" + + # Envoy admin: the API-level cluster must use the identity-derived name + # (main_) and there must be no URL-derived (cluster__) + # cluster. The URL-derived form is what the pre-change naming produced, so + # this assertion fails on the old naming scheme. The exact name set is + # captured so the post-update step can prove the NAME survived the update. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And I capture the Envoy cluster names prefixed "main_" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-main-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-main-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Main-API + version: v1.0 + context: /api-level-url-stable-main/$version + vhosts: + main: api-level-url-stable-main.local + upstream: + main: + # The host changes too (container alias of the same backend), proving + # the cluster survives a HOST edit, not only a path edit. The old + # URL-derived naming kept its name across path edits but renamed the + # cluster on any host or scheme change. + url: http://it-sample-backend:9080/version-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" to be ready with host "api-level-url-stable-main.local" + + When I clear all headers + And I set request host to "api-level-url-stable-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-main/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/version-b/endpoint" + + # After the HOST change the exact cluster-name set must be UNCHANGED: + # this proves the same main_ cluster survived the host edit (a + # rename to a different main_ would fail the unchanged step). The + # old naming would have minted a new cluster_http_it-sample-backend_9080 + # cluster here and dropped the previous one. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "main_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-main-api-v1.0" + Then the response should be successful + + Scenario: API-level sandbox upstream URL update (host and path change) routes to new backend + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-sandbox-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Sandbox-API + version: v1.0 + context: /api-level-url-stable-sandbox/$version + vhosts: + main: api-level-url-stable-sandbox-main.local + sandbox: api-level-url-stable-sandbox-sb.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + url: http://sample-backend:9080/sandbox-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-url-stable-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/sandbox-a/endpoint" + + # Capture the sandbox cluster-name set so the post-update step can prove + # the sandbox_ name survived the URL update. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And I capture the Envoy cluster names prefixed "sandbox_" + + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-sandbox-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-sandbox-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Sandbox-API + version: v1.0 + context: /api-level-url-stable-sandbox/$version + vhosts: + main: api-level-url-stable-sandbox-main.local + sandbox: api-level-url-stable-sandbox-sb.local + upstream: + main: + url: http://sample-backend:9080/api-main + sandbox: + # The sandbox host changes too (container alias of the same + # backend), so this update exercises a host edit on the sandbox + # cluster, not only a path edit. + url: http://it-sample-backend:9080/sandbox-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" to be ready with host "api-level-url-stable-sandbox-sb.local" + + When I clear all headers + And I set request host to "api-level-url-stable-sandbox-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-sandbox/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/sandbox-b/endpoint" + + # Envoy admin: the sandbox cluster must use the identity-derived name + # (sandbox_); no URL-derived cluster may exist, and the exact name + # set must be unchanged across the host edit (identity proof). Fails on + # the old URL-derived naming scheme. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "sandbox_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-sandbox-api-v1.0" + Then the response should be successful + + Scenario: API-level upstream ref resolves to the referenced upstreamDefinitions entry + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-default-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Default-API + version: v1.0 + context: /api-level-url-stable-default/$version + vhosts: + main: api-level-url-stable-default.local + upstreamDefinitions: + - name: backend-default + basePath: /api-main + upstreams: + - url: http://sample-backend:9080 + upstream: + main: + ref: backend-default + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" to be ready with host "api-level-url-stable-default.local" + + When I clear all headers + And I set request host to "api-level-url-stable-default.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-default/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-default-api-v1.0" + Then the response should be successful + + Scenario: API-level main and sandbox on the same backend host get separate identity-named clusters + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-collision-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Collision-API + version: v1.0 + context: /api-level-url-stable-collision/$version + vhosts: + main: api-level-url-stable-collision-main.local + sandbox: api-level-url-stable-collision-sb.local + upstream: + main: + url: http://sample-backend:9080/collision-main + sandbox: + url: http://sample-backend:9080/collision-sandbox + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" to be ready with host "api-level-url-stable-collision-main.local" + + # Main and sandbox share the same backend host:port but must route to their + # own base paths. The old URL-derived naming keyed the cluster on host and + # scheme only, so main and sandbox collapsed into one shared cluster here; + # identity naming gives each its own. + When I clear all headers + And I set request host to "api-level-url-stable-collision-main.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/collision-main/endpoint" + + When I clear all headers + And I set request host to "api-level-url-stable-collision-sb.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-collision/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/collision-sandbox/endpoint" + + # Envoy admin: an identity-named main_ and a sandbox_ cluster + # must both exist (they do not collide), and no URL-derived cluster may + # exist. Under the old naming both upstreams shared one cluster__ + # cluster, so this assertion fails on the previous scheme. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should contain "sandbox_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-collision-api-v1.0" + Then the response should be successful + + Scenario: Two APIs sharing the same backend host route independently through their own identity-named clusters + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-shared-a-v1.0 + spec: + displayName: API-Level-URL-Stable-Shared-A + version: v1.0 + context: /api-level-url-stable-shared-a/$version + vhosts: + main: api-level-url-stable-shared-a.local + upstream: + main: + url: http://sample-backend:9080/shared-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-a.local" + + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-shared-b-v1.0 + spec: + displayName: API-Level-URL-Stable-Shared-B + version: v1.0 + context: /api-level-url-stable-shared-b/$version + vhosts: + main: api-level-url-stable-shared-b.local + upstream: + main: + url: http://sample-backend:9080/shared-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" to be ready with host "api-level-url-stable-shared-b.local" + + # Two distinct APIs point at the same backend host:port. The old URL-derived + # naming made them share one cluster__ cluster; identity naming + # keys each cluster on its API ID, so the two APIs route independently to their + # own base paths under identity-named clusters and no URL-derived cluster exists. + When I clear all headers + And I set request host to "api-level-url-stable-shared-a.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-a/endpoint" + + When I clear all headers + And I set request host to "api-level-url-stable-shared-b.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-b/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-b/endpoint" + + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + + # Delete API-B and confirm API-A still routes, proving the two APIs own + # independent clusters (deleting one does not disturb the other). + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-shared-b-v1.0" + Then the response should be successful + + When I clear all headers + And I set request host to "api-level-url-stable-shared-a.local" + And I send a GET request to "http://localhost:8080/api-level-url-stable-shared-a/v1.0/endpoint" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/shared-a/endpoint" + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-shared-a-v1.0" + Then the response should be successful + + Scenario: API-level main upstream scheme and port change keeps the same identity-named cluster + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-scheme-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Scheme-API + version: v1.0 + context: /api-level-url-stable-scheme/$version + vhosts: + main: api-level-url-stable-scheme.local + upstream: + main: + url: http://sample-backend:9080/version-a + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/api-level-url-stable-scheme/v1.0/endpoint" to be ready with host "api-level-url-stable-scheme.local" + + # Capture the identity-derived cluster name while the upstream is plain http. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And I capture the Envoy cluster names prefixed "main_" + + # Change the upstream scheme (http -> https) AND port (9080 -> 9443) in one + # edit. The old URL-derived naming embedded scheme and port in the cluster + # name (cluster___), so this edit would have minted a new + # cluster_https_ cluster and dropped the previous one. Identity-based naming + # must keep the SAME main_ and never produce a cluster_https_. TLS + # routing itself is not asserted (there is no TLS echo backend), so there is no + # endpoint-readiness wait here; the cluster-set check below observes a settle + # window instead to cover xDS propagation. The cluster + # name is stable independent of upstream reachability. + Given I authenticate using basic auth as "admin" + When I update the API "api-level-url-stable-scheme-api-v1.0" with this configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: api-level-url-stable-scheme-api-v1.0 + spec: + displayName: API-Level-URL-Stable-Scheme-API + version: v1.0 + context: /api-level-url-stable-scheme/$version + vhosts: + main: api-level-url-stable-scheme.local + upstream: + main: + url: https://sample-backend:9443/version-b + operations: + - method: GET + path: /endpoint + """ + Then the response should be successful + + # The main_ name set must be UNCHANGED after the scheme/port edit, and + # no URL-derived cluster_https_ may appear. + When I clear all headers + And I send a GET request to "http://localhost:9901/clusters" + Then the response should be successful + And the response body should contain "main_" + And the response body should not contain "cluster_http_" + And the response body should not contain "cluster_https_" + And the Envoy cluster names prefixed "main_" should be unchanged + + Given I authenticate using basic auth as "admin" + When I delete the API "api-level-url-stable-scheme-api-v1.0" + Then the response should be successful diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index 4c078aae2b..7d35e4f361 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -151,10 +151,7 @@ func getFeaturePaths() []string { "features/upstream-connect-timeout.feature", "features/backend-timeout.feature", "features/llm-backend-timeout.feature", - "features/per-op-upstream-basic.feature", - "features/per-op-upstream-ref.feature", - "features/per-op-upstream-validation.feature", - "features/api-level-url-stable.feature", + "features/per-op-upstream.feature", // Runs late: it restarts the gateway-controller (reject/reconnect scenario), so keep it // after features that assume an uninterrupted controller. Verifies the DP->CP artifact push. "features/dp-to-cp.feature", From ac55cb7948ed11d85190f59ed0cfeddb54e94667 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Wed, 22 Jul 2026 11:57:15 +0530 Subject: [PATCH 6/9] test(gateway): cover per-operation upstream on match-form operations Add scenarios proving per-operation upstream refs apply to Gateway-API-style match operations (method + path.value + header matchers): a main ref on a match operation with a sibling match op falling back to the API-level upstream, a header matcher selecting the per-op ref on a shared path, and a sandbox ref on a match operation. Confirms the transform resolves the route from the match block and still applies the per-operation cluster. --- gateway/it/features/per-op-upstream.feature | 163 ++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/gateway/it/features/per-op-upstream.feature b/gateway/it/features/per-op-upstream.feature index 91f7b1f52a..a8cfeab6a3 100644 --- a/gateway/it/features/per-op-upstream.feature +++ b/gateway/it/features/per-op-upstream.feature @@ -1393,3 +1393,166 @@ Feature: Per-Operation Upstream Given I authenticate using basic auth as "admin" When I delete the API "api-level-url-stable-scheme-api-v1.0" Then the response should be successful + + # ===== match-form operations (Gateway-API-style method + path.value + headers) ===== + Scenario: Per-operation main ref on a match-form operation routes to the ref'd backend + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-match-basic-api-v1.0 + spec: + displayName: Per-Op-Match-Basic-API + version: v1.0 + context: /per-op-match-basic/$version + vhosts: + main: per-op-match-basic-main.local + upstreamDefinitions: + - name: match-main-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /match-main + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - match: + method: GET + path: + value: /users + upstream: + main: + ref: match-main-svc + - match: + method: GET + path: + value: /orders + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-match-basic/v1.0/users" to be ready with host "per-op-match-basic-main.local" + + When I clear all headers + And I set request host to "per-op-match-basic-main.local" + And I send a GET request to "http://localhost:8080/per-op-match-basic/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/match-main/users" + + When I clear all headers + And I set request host to "per-op-match-basic-main.local" + And I send a GET request to "http://localhost:8080/per-op-match-basic/v1.0/orders" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/orders" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-match-basic-api-v1.0" + Then the response should be successful + + Scenario: Header matcher on a match-form operation selects the per-operation ref + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-match-hdr-api-v1.0 + spec: + displayName: Per-Op-Match-Hdr-API + version: v1.0 + context: /per-op-match-hdr/$version + vhosts: + main: per-op-match-hdr-main.local + upstreamDefinitions: + - name: match-canary-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /canary + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - match: + method: GET + path: + value: /items + headers: + - name: x-variant + value: canary + upstream: + main: + ref: match-canary-svc + - match: + method: GET + path: + value: /items + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-match-hdr/v1.0/items" to be ready with host "per-op-match-hdr-main.local" + + When I clear all headers + And I set request host to "per-op-match-hdr-main.local" + And I set header "x-variant" to "canary" + And I send a GET request to "http://localhost:8080/per-op-match-hdr/v1.0/items" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/canary/items" + + When I clear all headers + And I set request host to "per-op-match-hdr-main.local" + And I send a GET request to "http://localhost:8080/per-op-match-hdr/v1.0/items" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/api-main/items" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-match-hdr-api-v1.0" + Then the response should be successful + + Scenario: Per-operation sandbox ref on a match-form operation routes sandbox traffic + Given I authenticate using basic auth as "admin" + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: per-op-match-sb-api-v1.0 + spec: + displayName: Per-Op-Match-SB-API + version: v1.0 + context: /per-op-match-sb/$version + vhosts: + main: per-op-match-sb-main.local + sandbox: per-op-match-sb-sandbox.local + upstreamDefinitions: + - name: match-sandbox-svc + upstreams: + - url: http://sample-backend:9080 + basePath: /match-sandbox + upstream: + main: + url: http://sample-backend:9080/api-main + operations: + - match: + method: GET + path: + value: /users + upstream: + sandbox: + ref: match-sandbox-svc + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/per-op-match-sb/v1.0/users" to be ready with host "per-op-match-sb-sandbox.local" + + When I clear all headers + And I set request host to "per-op-match-sb-sandbox.local" + And I send a GET request to "http://localhost:8080/per-op-match-sb/v1.0/users" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "path" should be "/match-sandbox/users" + + Given I authenticate using basic auth as "admin" + When I delete the API "per-op-match-sb-api-v1.0" + Then the response should be successful From 4097bf7e2d05f694999699df3d638b39ae18561d Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Wed, 22 Jul 2026 20:23:44 +0530 Subject: [PATCH 7/9] test(gateway): clarify the match-form scenarios section comment --- gateway/it/features/per-op-upstream.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/it/features/per-op-upstream.feature b/gateway/it/features/per-op-upstream.feature index a8cfeab6a3..68add3a991 100644 --- a/gateway/it/features/per-op-upstream.feature +++ b/gateway/it/features/per-op-upstream.feature @@ -1394,7 +1394,7 @@ Feature: Per-Operation Upstream When I delete the API "api-level-url-stable-scheme-api-v1.0" Then the response should be successful - # ===== match-form operations (Gateway-API-style method + path.value + headers) ===== + # ===== per-operation upstream on match-form operations (Gateway-API-style method + path.value + headers) ===== Scenario: Per-operation main ref on a match-form operation routes to the ref'd backend Given I authenticate using basic auth as "admin" When I deploy this API configuration: From 0f445324ef1f8d646aad57f5f5e7671f2cb2a56c Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Tue, 28 Jul 2026 14:47:41 +0530 Subject: [PATCH 8/9] docs(gateway): clarify the upstream ref regex scope Note that the ref pattern backs only API-level and per-operation upstream refs, and that definition names are validated separately, so the two checks are not mistaken for one shared rule. --- gateway/gateway-controller/pkg/config/api_validator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/gateway-controller/pkg/config/api_validator.go b/gateway/gateway-controller/pkg/config/api_validator.go index dbc3379945..129254b8bd 100644 --- a/gateway/gateway-controller/pkg/config/api_validator.go +++ b/gateway/gateway-controller/pkg/config/api_validator.go @@ -39,7 +39,7 @@ type APIValidator struct { // urlFriendlyNameRegex matches URL-safe characters for API names urlFriendlyNameRegex *regexp.Regexp // upstreamRefRegex enforces the schema pattern for API-level and per-op - // upstream refs + // upstream refs; definition names are checked by upstreamDefinitionNameRegex upstreamRefRegex *regexp.Regexp // policyValidator validates policy references and parameters policyValidator *PolicyValidator From c400a6fff4544b6435dce71a595c2e4cb9622e97 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Tue, 28 Jul 2026 17:54:35 +0530 Subject: [PATCH 9/9] fix(gateway): reject a non-positive upstreamDefinition connect timeout The management validator accepted a zero connect timeout while the transformer requires a positive value, so a definition carrying "0s" was accepted at deploy time and then failed when the deployment was translated. Because runtime configs are rebuilt from stored configurations on startup, such a definition also stopped the controller from starting again. Validate the connect timeout as positive alongside the existing format check, and cover the rejection in the validator unit test and the integration scenario. --- gateway/it/features/per-op-upstream.feature | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/gateway/it/features/per-op-upstream.feature b/gateway/it/features/per-op-upstream.feature index 68add3a991..6cf995d3bb 100644 --- a/gateway/it/features/per-op-upstream.feature +++ b/gateway/it/features/per-op-upstream.feature @@ -899,10 +899,10 @@ Feature: Per-Operation Upstream And the JSON response field "status" should be "error" And the response body should contain "must match pattern" - # The management validator deliberately accepts a zero connect timeout; only the - # duration format is enforced here. Runtime timeout semantics are the translator's - # concern, so deployment must succeed. - Scenario: Zero connect timeout in an upstreamDefinition is accepted + # Zero does not disable a connect timeout: the transformer requires a positive value, so + # the validator rejects it at deploy time rather than accepting a definition that cannot + # be translated afterwards. + Scenario: Zero connect timeout in an upstreamDefinition is rejected Given I authenticate using basic auth as "admin" When I deploy this API configuration: """ @@ -932,8 +932,10 @@ Feature: Per-Operation Upstream main: ref: slow-svc """ - Then the response status code should be 201 + Then the response status code should be 400 And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "must be positive" # ===== from api-level-url-stable.feature ===== Scenario: API-level main upstream URL update (host and path change) routes to new backend (URL-stable cluster naming)