diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 3748ae810..55b4e4ac8 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -930,7 +930,10 @@ type CreateRESTAPIRequest struct { // Upstream Upstream backend configuration with main and sandbox endpoints Upstream Upstream `json:"upstream" yaml:"upstream"` - Version string `binding:"required" json:"version" yaml:"version"` + + // UpstreamDefinitions List of reusable named upstream definitions, referenced by `ref` from both API-level and operation-level upstreams. + UpstreamDefinitions *[]ReusableUpstream `json:"upstreamDefinitions,omitempty" yaml:"upstreamDefinitions,omitempty"` + Version string `binding:"required" json:"version" yaml:"version"` } // CreateRESTAPIRequestLifeCycleStatus Current lifecycle status of the API @@ -1924,11 +1927,26 @@ type OperationRequest struct { // Policies List of policies to be applied on the operation Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + + // Upstream Per-operation upstream override. Each sub-field must reference a named entry in upstreamDefinitions. Missing sub-fields fall back to the API-level upstream. At least one of main or sandbox must be set. + Upstream *OperationUpstream `json:"upstream,omitempty" yaml:"upstream,omitempty"` } // OperationRequestMethod HTTP method for the operation type OperationRequestMethod string +// OperationUpstream Per-operation upstream override. Each sub-field must reference a named entry in upstreamDefinitions. Missing sub-fields fall back to the API-level upstream. At least one of main or sandbox must be set. +type OperationUpstream struct { + Main *struct { + // Ref Name of a ReusableUpstream entry in the API's upstreamDefinitions pool. Used by both API-level and operation-level upstream refs. + Ref UpstreamReference `json:"ref" yaml:"ref"` + } `json:"main,omitempty" yaml:"main,omitempty"` + Sandbox *struct { + // Ref Name of a ReusableUpstream entry in the API's upstreamDefinitions pool. Used by both API-level and operation-level upstream refs. + Ref UpstreamReference `json:"ref" yaml:"ref"` + } `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` +} + // Organization defines model for Organization. type Organization struct { // CreatedAt Timestamp when the organization was created @@ -2075,7 +2093,10 @@ type RESTAPI struct { // Upstream Upstream backend configuration with main and sandbox endpoints Upstream Upstream `json:"upstream" yaml:"upstream"` - Version string `binding:"required" json:"version" yaml:"version"` + + // UpstreamDefinitions List of reusable named upstream definitions, referenced by `ref` from both API-level and operation-level upstreams. + UpstreamDefinitions *[]ReusableUpstream `json:"upstreamDefinitions,omitempty" yaml:"upstreamDefinitions,omitempty"` + Version string `binding:"required" json:"version" yaml:"version"` } // RESTAPILifeCycleStatus Current lifecycle status of the API @@ -2231,6 +2252,27 @@ type ResourceWiseRateLimitingConfig struct { Resources []RateLimitingResourceLimit `binding:"required" json:"resources" yaml:"resources"` } +// ReusableUpstream A reusable named upstream definition. Referenced by name from API-level and operation-level upstream refs. +type ReusableUpstream struct { + // BasePath Base path prefix prepended to all requests routed to this upstream (e.g., /api/v2) + BasePath *string `json:"basePath,omitempty" yaml:"basePath,omitempty"` + + // Name Name of a ReusableUpstream entry in the API's upstreamDefinitions pool. Used by both API-level and operation-level upstream refs. + Name UpstreamReference `json:"name" yaml:"name"` + + // Timeout Timeout configuration for upstream requests + Timeout *UpstreamTimeout `json:"timeout,omitempty" yaml:"timeout,omitempty"` + + // Upstreams List of backend targets with optional weights for load balancing + Upstreams []struct { + // Url Backend URL (host and port only; path comes from basePath) + Url string `json:"url" yaml:"url"` + + // Weight Relative weight for load balancing across the definition's targets; when omitted, the gateway applies its default endpoint weight + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + } `binding:"required" json:"upstreams" yaml:"upstreams"` +} + // RouteException defines model for RouteException. type RouteException struct { // Methods HTTP methods @@ -2568,8 +2610,8 @@ type UpstreamDefinition struct { // Auth Authentication configuration for upstream endpoints Auth *UpstreamAuth `json:"auth,omitempty" yaml:"auth,omitempty"` - // Ref Reference to a predefined upstreamDefinition - Ref *string `json:"ref,omitempty" yaml:"ref,omitempty"` + // Ref Name of a ReusableUpstream entry in the API's upstreamDefinitions pool. Used by both API-level and operation-level upstream refs. + Ref *UpstreamReference `json:"ref,omitempty" yaml:"ref,omitempty"` // Url Direct backend URL to route traffic to Url *string `json:"url,omitempty" yaml:"url,omitempty"` @@ -2582,6 +2624,15 @@ type UpstreamDefinition0 = interface{} // UpstreamDefinition1 defines model for . type UpstreamDefinition1 = interface{} +// UpstreamReference Name of a ReusableUpstream entry in the API's upstreamDefinitions pool. Used by both API-level and operation-level upstream refs. +type UpstreamReference = string + +// UpstreamTimeout Timeout configuration for upstream requests +type UpstreamTimeout struct { + // Connect Connection timeout duration (e.g., "5s", "500ms") + Connect *string `json:"connect,omitempty" yaml:"connect,omitempty"` +} + // UserAPIKeyItem defines model for UserAPIKeyItem. type UserAPIKeyItem struct { // AllowedTargets Comma-separated list of allowed gateways; 'ALL' means unrestricted diff --git a/platform-api/internal/dto/api.go b/platform-api/internal/dto/api.go index 77f56366d..fd7a7455c 100644 --- a/platform-api/internal/dto/api.go +++ b/platform-api/internal/dto/api.go @@ -137,15 +137,16 @@ type Vhosts struct { // APIYAMLData represents a basic spec section of the API deployment YAML type APIYAMLData struct { - DisplayName string `yaml:"displayName"` - Version string `yaml:"version"` - Context string `yaml:"context"` - SubscriptionPlans []string `yaml:"subscriptionPlans,omitempty"` - Vhosts *Vhosts `yaml:"vhosts,omitempty"` - Upstream *UpstreamYAML `yaml:"upstream,omitempty"` - Policies []Policy `yaml:"policies,omitempty"` - Operations []api.OperationRequest `yaml:"operations,omitempty"` - Channels []api.ChannelRequest `yaml:"channels,omitempty"` + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + Context string `yaml:"context"` + SubscriptionPlans []string `yaml:"subscriptionPlans,omitempty"` + Vhosts *Vhosts `yaml:"vhosts,omitempty"` + Upstream *UpstreamYAML `yaml:"upstream,omitempty"` + UpstreamDefinitions []UpstreamDefinitionYAML `yaml:"upstreamDefinitions,omitempty"` + Policies []Policy `yaml:"policies,omitempty"` + Operations []api.OperationRequest `yaml:"operations,omitempty"` + Channels []api.ChannelRequest `yaml:"channels,omitempty"` } // UpstreamYAML represents the upstream configuration for API deployment YAML @@ -160,10 +161,28 @@ type UpstreamTarget struct { Ref string `yaml:"ref,omitempty"` } +// UpstreamDefinitionYAML represents a reusable named upstream definition in the deployment YAML +type UpstreamDefinitionYAML struct { + Name string `yaml:"name"` + BasePath string `yaml:"basePath,omitempty"` + Timeout *UpstreamTimeoutYAML `yaml:"timeout,omitempty"` + Upstreams []UpstreamBackendYAML `yaml:"upstreams"` +} + +// UpstreamBackendYAML represents a single backend target within an upstream definition. +type UpstreamBackendYAML struct { + URL string `yaml:"url"` + Weight *int `yaml:"weight,omitempty"` +} + +// UpstreamTimeoutYAML represents the timeout configuration for an upstream definition. +type UpstreamTimeoutYAML struct { + Connect string `yaml:"connect,omitempty"` +} + // APIListResponse represents a paginated list of APIs (constitution-compliant) type APIListResponse struct { Count int `json:"count" yaml:"count"` // Number of items in current response List []*API `json:"list" yaml:"list"` // Array of API objects Pagination Pagination `json:"pagination" yaml:"pagination"` // Pagination metadata } - diff --git a/platform-api/internal/handler/api_upstream_integration_test.go b/platform-api/internal/handler/api_upstream_integration_test.go new file mode 100644 index 000000000..cec34d8aa --- /dev/null +++ b/platform-api/internal/handler/api_upstream_integration_test.go @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 handler + +import ( + "bytes" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/service" + "github.com/wso2/api-platform/platform-api/internal/utils" + + _ "github.com/mattn/go-sqlite3" +) + +const restAPIBase = "/api/v0.9/rest-apis" +const restAPIOrg = "org-api-it-001" +const restAPIProject = "api-it-project" + +// setupAPIHandlerEnv builds the real route -> handler -> service -> repository stack over an +// in-memory SQLite DB, seeded with a test organization and project, so tests can assert the +// HTTP status codes the REST API handler maps service errors to. +func setupAPIHandlerEnv(t *testing.T) (http.Handler, func()) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "test.db") + sqlDB, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if _, err := sqlDB.Exec("PRAGMA foreign_keys = ON"); err != nil { + t.Fatalf("enable foreign keys: %v", err) + } + db := &database.DB{DB: sqlDB} + + schema, err := os.ReadFile(filepath.Join("..", "database", "schema.sqlite.sql")) + if err != nil { + t.Fatalf("read schema: %v", err) + } + if _, err = db.Exec(string(schema)); err != nil { + t.Fatalf("apply schema: %v", err) + } + if _, err = db.Exec(`INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) + VALUES ('` + restAPIOrg + `', 'test-api-org', 'Test API Org', 'default', 'idp-ref', datetime('now'), datetime('now'))`); err != nil { + t.Fatalf("insert org: %v", err) + } + if _, err = db.Exec(`INSERT INTO projects (uuid, handle, display_name, organization_uuid, description, created_at, updated_at) + VALUES ('proj-api-it-001', '` + restAPIProject + `', 'API IT Project', '` + restAPIOrg + `', '', datetime('now'), datetime('now'))`); err != nil { + t.Fatalf("insert project: %v", err) + } + + identityService := service.NewIdentityService(repository.NewUserIdentityMappingRepo(db)) + apiService := service.NewAPIService( + repository.NewAPIRepo(db), repository.NewProjectRepo(db), repository.NewOrganizationRepo(db), + repository.NewGatewayRepo(db), + nil, // deploymentRepo: unused by the create/update validation paths under test + repository.NewSubscriptionPlanRepo(db), repository.NewCustomPolicyRepo(db), + nil, // gatewayEventsService: unused by create/update in these tests + &utils.APIUtil{}, slog.Default(), repository.NewAuditRepo(db), identityService, + ) + + h := NewAPIHandler(apiService, identityService, slog.Default()) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + return middleware.NewTestContextMiddleware(mux), func() { _ = sqlDB.Close() } +} + +// doRESTAPIJSON issues an authenticated JSON request against r, scoped to restAPIOrg. +func doRESTAPIJSON(t *testing.T, r http.Handler, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + req, _ := http.NewRequest(method, path, bytes.NewBufferString(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("X-Test-Org", restAPIOrg) + req.Header.Set("X-Test-User", "alice") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +// restAPIBody returns a create/update request body whose single operation references the +// given upstream definition name; the declared pool contains only "alt-backend". +func restAPIBody(opRef string) string { + return fmt.Sprintf(`{ + "displayName": "Upstream IT API", + "version": "v1.0", + "context": "/upstream-it", + "projectId": %q, + "upstream": {"main": {"url": "http://main:8080"}}, + "upstreamDefinitions": [{"name": "alt-backend", "upstreams": [{"url": "http://alt:9000"}]}], + "operations": [{"request": {"method": "GET", "path": "/x", "upstream": {"main": {"ref": %q}}}}] + }`, restAPIProject, opRef) +} + +// TestAPIHandler_CreateInvalidUpstreamRefReturns400 pins the invalid-upstream mapping to +// HTTP 400 on create: an unresolved per-operation ref must surface as a validation +// failure, not an internal error. +func TestAPIHandler_CreateInvalidUpstreamRefReturns400(t *testing.T) { + r, cleanup := setupAPIHandlerEnv(t) + defer cleanup() + + w := doRESTAPIJSON(t, r, http.MethodPost, restAPIBase, restAPIBody("missing")) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for unresolved per-op ref, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "not declared in upstreamDefinitions") { + t.Fatalf("expected unresolved-ref detail in response, got: %s", w.Body.String()) + } +} + +// TestAPIHandler_CreateInvalidBasePathReturns400 pins the same mapping for the +// upstreamDefinitions basePath contract (must start with '/' and must not end with '/'). +func TestAPIHandler_CreateInvalidBasePathReturns400(t *testing.T) { + r, cleanup := setupAPIHandlerEnv(t) + defer cleanup() + + body := fmt.Sprintf(`{ + "displayName": "Upstream IT API", + "version": "v1.0", + "context": "/upstream-it", + "projectId": %q, + "upstream": {"main": {"url": "http://main:8080"}}, + "upstreamDefinitions": [{"name": "alt-backend", "basePath": "/api/v2/", "upstreams": [{"url": "http://alt:9000"}]}], + "operations": [{"request": {"method": "GET", "path": "/x", "upstream": {"main": {"ref": "alt-backend"}}}}] + }`, restAPIProject) + w := doRESTAPIJSON(t, r, http.MethodPost, restAPIBase, body) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for basePath with trailing '/', got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "basePath") { + t.Fatalf("expected basePath detail in response, got: %s", w.Body.String()) + } +} + +// TestAPIHandler_UpdateInvalidUpstreamRefReturns400 pins the update-path mapping: a valid +// API is created first, then an update that dangles its per-operation ref must return 400. +func TestAPIHandler_UpdateInvalidUpstreamRefReturns400(t *testing.T) { + r, cleanup := setupAPIHandlerEnv(t) + defer cleanup() + + created := doRESTAPIJSON(t, r, http.MethodPost, restAPIBase, restAPIBody("alt-backend")) + if created.Code != http.StatusCreated { + t.Fatalf("expected 201 for valid create, got %d: %s", created.Code, created.Body.String()) + } + var createdAPI struct { + Id string `json:"id"` + } + if err := json.Unmarshal(created.Body.Bytes(), &createdAPI); err != nil || createdAPI.Id == "" { + t.Fatalf("could not read created API id: %v, body: %s", err, created.Body.String()) + } + + w := doRESTAPIJSON(t, r, http.MethodPut, restAPIBase+"/"+createdAPI.Id, restAPIBody("missing")) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for unresolved per-op ref on update, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "not declared in upstreamDefinitions") { + t.Fatalf("expected unresolved-ref detail in response, got: %s", w.Body.String()) + } +} diff --git a/platform-api/internal/model/api.go b/platform-api/internal/model/api.go index 64d473962..00c56a684 100644 --- a/platform-api/internal/model/api.go +++ b/platform-api/internal/model/api.go @@ -43,14 +43,15 @@ type API struct { } type RestAPIConfig struct { - Name string `json:"name,omitempty"` - Version string `json:"version,omitempty"` - Context *string `json:"context,omitempty"` - Transport []string `json:"transport,omitempty"` - Upstream UpstreamConfig `json:"upstream,omitempty"` - Policies []Policy `json:"policies,omitempty"` - Operations []Operation `json:"operations,omitempty"` - SubscriptionPlans []string `json:"subscriptionPlans,omitempty"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Context *string `json:"context,omitempty"` + Transport []string `json:"transport,omitempty"` + Upstream UpstreamConfig `json:"upstream,omitempty"` + UpstreamDefinitions []ReusableUpstream `json:"upstreamDefinitions,omitempty"` + Policies []Policy `json:"policies,omitempty"` + Operations []Operation `json:"operations,omitempty"` + SubscriptionPlans []string `json:"subscriptionPlans,omitempty"` } // TableName returns the table name for the API model @@ -84,9 +85,10 @@ type Channel struct { // OperationRequest represents operation request details type OperationRequest struct { - Method string `json:"method,omitempty"` - Path string `json:"path,omitempty"` - Policies []Policy `json:"policies,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Policies []Policy `json:"policies,omitempty"` + Upstream *OperationUpstream `json:"upstream,omitempty"` } // ChannelRequest represents channel request details diff --git a/platform-api/internal/model/upstream.go b/platform-api/internal/model/upstream.go index 258a05d32..a80323ec4 100644 --- a/platform-api/internal/model/upstream.go +++ b/platform-api/internal/model/upstream.go @@ -35,3 +35,33 @@ type UpstreamAuth struct { Header string `json:"header,omitempty" db:"-"` Value string `json:"value,omitempty" db:"-"` } + +// ReusableUpstream represents a named reusable upstream definition +type ReusableUpstream struct { + Name string `json:"name" db:"-"` + BasePath string `json:"basePath,omitempty" db:"-"` + Timeout *UpstreamTimeout `json:"timeout,omitempty" db:"-"` + Upstreams []UpstreamTarget `json:"upstreams" db:"-"` +} + +// UpstreamTarget represents a backend target with an optional load-balancing weight +type UpstreamTarget struct { + URL string `json:"url" db:"-"` + Weight *int `json:"weight,omitempty" db:"-"` +} + +// UpstreamTimeout represents upstream timeout configuration +type UpstreamTimeout struct { + Connect string `json:"connect,omitempty" db:"-"` +} + +// OperationUpstream represents a per-operation upstream override +type OperationUpstream struct { + Main *OperationUpstreamRef `json:"main,omitempty" db:"-"` + Sandbox *OperationUpstreamRef `json:"sandbox,omitempty" db:"-"` +} + +// OperationUpstreamRef references a reusable upstream definition by name +type OperationUpstreamRef struct { + Ref string `json:"ref" db:"-"` +} diff --git a/platform-api/internal/repository/api_upstream_test.go b/platform-api/internal/repository/api_upstream_test.go new file mode 100644 index 000000000..a027275c0 --- /dev/null +++ b/platform-api/internal/repository/api_upstream_test.go @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 repository + +import ( + "reflect" + "testing" + + "github.com/wso2/api-platform/platform-api/internal/model" +) + +// TestAPIRepo_CreateAndRead_PreservesUpstreamDefinitionsAndPerOp verifies the reusable pool +// and per-operation upstream refs survive the configuration JSON blob's database round trip. +func TestAPIRepo_CreateAndRead_PreservesUpstreamDefinitionsAndPerOp(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewAPIRepo(db) + + orgUUID := "org-upstream-crud-001" + projectUUID := "project-upstream-crud-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + weight := 80 + api := &model.API{ + Handle: "upstream-pool-api", + Name: "Upstream Pool API", + Version: "1.0.0", + CreatedBy: "test-user", + ProjectID: projectUUID, + OrganizationID: orgUUID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{ + Name: "Upstream Pool API", + Version: "1.0.0", + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "http://default-backend:8080"}, + }, + UpstreamDefinitions: []model.ReusableUpstream{ + { + Name: "alt-backend", + BasePath: "/api/v2", + Timeout: &model.UpstreamTimeout{Connect: "5s"}, + Upstreams: []model.UpstreamTarget{{URL: "http://alt:9090", Weight: &weight}}, + }, + }, + Operations: []model.Operation{ + {Request: &model.OperationRequest{ + Method: "GET", Path: "/whoami", + Upstream: &model.OperationUpstream{Main: &model.OperationUpstreamRef{Ref: "alt-backend"}}, + }}, + {Request: &model.OperationRequest{Method: "GET", Path: "/ping"}}, + }, + }, + } + + if err := repo.CreateAPI(api); err != nil { + t.Fatalf("CreateAPI failed: %v", err) + } + defer func() { + if err := repo.DeleteAPI(api.ID, orgUUID); err != nil { + t.Errorf("DeleteAPI cleanup failed: %v", err) + } + }() + + created, err := repo.GetAPIByUUID(api.ID, orgUUID) + if err != nil { + t.Fatalf("GetAPIByUUID failed: %v", err) + } + if created == nil { + t.Fatal("GetAPIByUUID returned nil") + } + + if !reflect.DeepEqual(created.Configuration.UpstreamDefinitions, api.Configuration.UpstreamDefinitions) { + t.Fatalf("upstreamDefinitions did not survive the round trip:\ngot %+v\nwant %+v", + created.Configuration.UpstreamDefinitions, api.Configuration.UpstreamDefinitions) + } + if !reflect.DeepEqual(created.Configuration.Operations, api.Configuration.Operations) { + t.Fatalf("operations (with per-op upstream) did not survive the round trip:\ngot %+v\nwant %+v", + created.Configuration.Operations, api.Configuration.Operations) + } +} diff --git a/platform-api/internal/service/api.go b/platform-api/internal/service/api.go index 3c1e5248f..df8185d43 100644 --- a/platform-api/internal/service/api.go +++ b/platform-api/internal/service/api.go @@ -22,9 +22,11 @@ import ( "errors" "fmt" "log/slog" + "net/url" "regexp" "strconv" "strings" + "time" "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/internal/apperror" @@ -744,6 +746,11 @@ func (s *APIService) validateCreateAPIRequest(req *api.CreateRESTAPIRequest, org return err } + // Validate the upstreamDefinitions pool, the API-level url-or-ref union, and that every ref resolves + if err := s.validateUpstreamRefs(req.UpstreamDefinitions, req.Upstream, req.Operations); err != nil { + return apperror.ValidationFailed.Wrap(err, err.Error()) + } + return nil } @@ -776,6 +783,193 @@ func (s *APIService) validateSubscriptionPlans(planHandles *[]string, orgUUID st return nil } +// upstreamRefNameRe is the contract an upstreamDefinition name and a per-operation ref must +// match. It mirrors the UpstreamReference pattern published in the OpenAPI spec and the gateway +// validator, so a name the platform accepts is never rejected at deploy. +var upstreamRefNameRe = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`) + +// connectTimeoutRe is the contract an upstreamDefinition connect timeout must match: a whole or +// decimal value in ms, s, m, or h. It mirrors the connect pattern published in the OpenAPI spec +// and the gateway validator, since time.ParseDuration alone also accepts ns/us units and compound +// values like "1h30m" that the gateway rejects at deploy. +var connectTimeoutRe = regexp.MustCompile(`^\d+(\.\d+)?(ms|s|m|h)$`) + +// upstreamBasePathRe is the contract an upstreamDefinition basePath must match: it must start +// with '/' and must not end with '/'. It mirrors the basePath pattern published in the OpenAPI +// spec and the gateway validator, so a value the platform accepts is never rejected at deploy. +var upstreamBasePathRe = regexp.MustCompile(`^/[a-zA-Z0-9\-._~!$&'()*+,;=:@%/]*[^/]$`) + +// validateUpstreamReferenceName enforces the shared UpstreamReference contract. +func validateUpstreamReferenceName(value, field string) error { + if value == "" { + return fmt.Errorf("%s is required", field) + } + if len(value) > 100 { + return fmt.Errorf("%s must not exceed 100 characters", field) + } + if !upstreamRefNameRe.MatchString(value) { + return fmt.Errorf("%s must match pattern ^[a-zA-Z0-9\\-_]+$", field) + } + return nil +} + +// resolveUpstreamRef enforces the UpstreamReference contract on a ref value and ensures it +// names a declared upstreamDefinition. field is the full path of the ref, for example +// "upstream.main.ref" or "operations[0].upstream.main.ref". +func resolveUpstreamRef(ref, field string, defined map[string]bool) error { + refName := strings.TrimSpace(ref) + if err := validateUpstreamReferenceName(refName, field); err != nil { + return err + } + if !defined[refName] { + return fmt.Errorf("%s references upstream definition %q which is not declared in upstreamDefinitions", field, refName) + } + return nil +} + +// validateAPIUpstreamEndpoint validates the API-level url-or-ref union and resolves refs. +func validateAPIUpstreamEndpoint(endpoint api.UpstreamDefinition, field string, defined map[string]bool) error { + hasURL := endpoint.Url != nil + hasRef := endpoint.Ref != nil + if hasURL && hasRef { + return fmt.Errorf("%s must specify exactly one of url or ref", field) + } + if !hasURL && !hasRef { + return fmt.Errorf("%s must specify either url or ref", field) + } + + if hasURL { + // Match the gateway validator: trim only to detect an empty value, then + // parse the original string, so the platform never accepts a URL the + // gateway later rejects at deploy. + if strings.TrimSpace(*endpoint.Url) == "" { + return fmt.Errorf("%s.url is required", field) + } + parsed, err := url.Parse(*endpoint.Url) + if err != nil { + return fmt.Errorf("%s.url is invalid: %w", field, err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("%s.url must use http or https scheme", field) + } + if parsed.Host == "" { + return fmt.Errorf("%s.url must include a host", field) + } + return nil + } + + return resolveUpstreamRef(*endpoint.Ref, field+".ref", defined) +} + +// validateUpstreamRefs ensures upstreamDefinitions are well-formed (unique, legal name, valid +// host-only url, legal basePath, in-range weight, positive timeout), validates each API-level +// url-or-ref union, and ensures every API-level and per-operation ref resolves. +func (s *APIService) validateUpstreamRefs(upstreamDefs *[]api.ReusableUpstream, upstream api.Upstream, operations *[]api.Operation) error { + defined := make(map[string]bool) + if upstreamDefs != nil { + for defIdx, d := range *upstreamDefs { + definitionPath := fmt.Sprintf("upstreamDefinitions[%d]", defIdx) + if err := validateUpstreamReferenceName(d.Name, definitionPath+".name"); err != nil { + return err + } + if defined[d.Name] { + return fmt.Errorf("%s.name duplicates upstream definition %q", definitionPath, d.Name) + } + if len(d.Upstreams) == 0 { + return fmt.Errorf("%s.upstreams must declare at least one upstream url", definitionPath) + } + for upstreamIdx, backend := range d.Upstreams { + backendPath := fmt.Sprintf("%s.upstreams[%d]", definitionPath, upstreamIdx) + if backend.Url == "" { + return fmt.Errorf("%s.url is required", backendPath) + } + parsed, err := url.Parse(backend.Url) + if err != nil { + return fmt.Errorf("%s.url %q is invalid: %w", backendPath, backend.Url, err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("%s.url must use http or https scheme", backendPath) + } + if parsed.Host == "" { + return fmt.Errorf("%s.url must include a host", backendPath) + } + // Match the gateway validator: a non-root path, query, or fragment is not + // part of the upstream cluster and must be configured elsewhere. + if parsed.Path != "" && parsed.Path != "/" { + return fmt.Errorf("%s.url must not include a path; set it in upstreamDefinitions[].basePath", backendPath) + } + if parsed.RawQuery != "" || parsed.ForceQuery { + return fmt.Errorf("%s.url must not include a query string; only host[:port] is used", backendPath) + } + if parsed.Fragment != "" { + return fmt.Errorf("%s.url must not include a fragment; only host[:port] is used", backendPath) + } + if backend.Weight != nil && (*backend.Weight < 0 || *backend.Weight > 100) { + return fmt.Errorf("%s.weight must be between 0 and 100", backendPath) + } + } + // Match the gateway validator: validated raw (no trim), and an omitted basePath + // means root. An empty string is treated as unset because omitempty drops it + // from the deployment YAML before the gateway ever sees it. + if d.BasePath != nil && *d.BasePath != "" && !upstreamBasePathRe.MatchString(*d.BasePath) { + return fmt.Errorf("%s.basePath must start with '/' and must not end with '/'; omit for root (for example /api/v2)", definitionPath) + } + // Match the gateway validator: trim first and validate only a non-empty + // value, so a blank or whitespace-only connect is treated as "unset" here + // exactly as the gateway treats it, rather than rejected. + if d.Timeout != nil && d.Timeout.Connect != nil { + if timeoutValue := strings.TrimSpace(*d.Timeout.Connect); timeoutValue != "" { + timeoutPath := definitionPath + ".timeout.connect" + duration, err := time.ParseDuration(timeoutValue) + if err != nil { + return fmt.Errorf("%s %q is invalid: %w", timeoutPath, timeoutValue, err) + } + if !connectTimeoutRe.MatchString(timeoutValue) { + return fmt.Errorf("%s must use one unsigned unit ms, s, m, or h (for example 5s or 500ms)", timeoutPath) + } + if duration <= 0 { + return fmt.Errorf("%s must be positive", timeoutPath) + } + } + } + defined[d.Name] = true + } + } + + if err := validateAPIUpstreamEndpoint(upstream.Main, "upstream.main", defined); err != nil { + return err + } + if upstream.Sandbox != nil { + if err := validateAPIUpstreamEndpoint(*upstream.Sandbox, "upstream.sandbox", defined); err != nil { + return err + } + } + + if operations != nil { + for opIdx, op := range *operations { + if op.Request.Upstream == nil { + continue + } + operationPath := fmt.Sprintf("operations[%d].upstream", opIdx) + operationUpstream := op.Request.Upstream + if operationUpstream.Main == nil && operationUpstream.Sandbox == nil { + return fmt.Errorf("%s must set at least one of main or sandbox", operationPath) + } + if operationUpstream.Main != nil { + if err := resolveUpstreamRef(operationUpstream.Main.Ref, operationPath+".main.ref", defined); err != nil { + return err + } + } + if operationUpstream.Sandbox != nil { + if err := resolveUpstreamRef(operationUpstream.Sandbox.Ref, operationPath+".sandbox.ref", defined); err != nil { + return err + } + } + } + } + return nil +} + // applyAPIUpdates applies update request fields to an existing API model and handles backend services func (s *APIService) applyAPIUpdates(existingAPIModel *model.API, req *api.RESTAPI, orgId string) (*api.RESTAPI, error) { // Validate update request @@ -818,6 +1012,15 @@ func (s *APIService) applyAPIUpdates(existingAPIModel *model.API, req *api.RESTA // (unredacted) whenever the incoming value is empty. existingAPI.Upstream = preserveUpstreamAuthOnAPIUpdate(existingAPIModel.Configuration.Upstream, req.Upstream) } + if req.UpstreamDefinitions != nil { + existingAPI.UpstreamDefinitions = req.UpstreamDefinitions + } + + // Validate the merged configuration so a partial update cannot leave a per-operation ref + // pointing at a definition the update removed. + if err := s.validateUpstreamRefs(existingAPI.UpstreamDefinitions, existingAPI.Upstream, existingAPI.Operations); err != nil { + return nil, apperror.ValidationFailed.Wrap(err, err.Error()) + } return existingAPI, nil } @@ -1033,21 +1236,22 @@ func (s *APIService) createRequestToRESTAPI(req *api.CreateRESTAPIRequest, handl } return &api.RESTAPI{ - Channels: req.Channels, - Context: req.Context, - CreatedBy: req.CreatedBy, - Description: req.Description, - Id: utils.StringPtrIfNotEmpty(handle), - Kind: req.Kind, - LifeCycleStatus: lifecycle, - DisplayName: req.DisplayName, - Operations: req.Operations, - Policies: req.Policies, - ProjectId: req.ProjectId, - SubscriptionPlans: req.SubscriptionPlans, - Transport: req.Transport, - Upstream: req.Upstream, - Version: req.Version, + Channels: req.Channels, + Context: req.Context, + CreatedBy: req.CreatedBy, + Description: req.Description, + Id: utils.StringPtrIfNotEmpty(handle), + Kind: req.Kind, + LifeCycleStatus: lifecycle, + DisplayName: req.DisplayName, + Operations: req.Operations, + Policies: req.Policies, + ProjectId: req.ProjectId, + SubscriptionPlans: req.SubscriptionPlans, + Transport: req.Transport, + Upstream: req.Upstream, + UpstreamDefinitions: req.UpstreamDefinitions, + Version: req.Version, } } diff --git a/platform-api/internal/service/api_test.go b/platform-api/internal/service/api_test.go index e74b70e33..0904ed8a3 100644 --- a/platform-api/internal/service/api_test.go +++ b/platform-api/internal/service/api_test.go @@ -277,7 +277,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Context: "/test", Version: "v1", ProjectId: projectID, - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, mockNameVersionExists: false, wantErr: false, @@ -292,7 +292,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Context: "/test", Version: "v1", ProjectId: projectID, - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, mockHandleExists: true, wantErr: true, @@ -305,7 +305,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Context: "/test", Version: "v1", ProjectId: projectID, - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, mockNameVersionExists: true, wantErr: true, @@ -320,7 +320,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Context: "/test", Version: "v1", ProjectId: projectID, - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, wantErr: true, expectedErr: apperror.ValidationFailed, @@ -332,7 +332,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Context: "/test", Version: "v1", ProjectId: "", - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, wantErr: true, errContains: "project id is required", @@ -344,7 +344,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Context: "invalid", Version: "v1", ProjectId: projectID, - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, wantErr: true, expectedErr: apperror.ValidationFailed, @@ -356,7 +356,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Context: "/test", Version: "", ProjectId: projectID, - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, wantErr: true, expectedErr: apperror.ValidationFailed, @@ -369,7 +369,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Version: "v1", ProjectId: projectID, LifeCycleStatus: createStatusPtr("INVALID_STATE"), - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, wantErr: true, expectedErr: apperror.ValidationFailed, @@ -382,7 +382,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Version: "v1", ProjectId: projectID, Kind: ptr("INVALID_TYPE"), - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, wantErr: true, expectedErr: apperror.ValidationFailed, @@ -395,7 +395,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Version: "v1", ProjectId: projectID, Transport: slicePtr([]string{"invalid"}), - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, wantErr: true, expectedErr: apperror.ValidationFailed, @@ -408,7 +408,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Version: "v1", ProjectId: projectID, LifeCycleStatus: createStatusPtr("PUBLISHED"), - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, mockNameVersionExists: false, wantErr: false, @@ -423,7 +423,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Version: "v1", ProjectId: projectID, Kind: ptr("RestApi"), - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, mockNameVersionExists: false, wantErr: false, @@ -438,7 +438,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Version: "v1", ProjectId: projectID, Transport: slicePtr([]string{"https"}), - Upstream: api.Upstream{}, + Upstream: validUpstream(), }, mockNameVersionExists: false, wantErr: false, @@ -452,7 +452,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Context: "/test", Version: "v1", ProjectId: projectID, - Upstream: api.Upstream{}, + Upstream: validUpstream(), Operations: &[]api.Operation{ { Request: api.OperationRequest{ @@ -471,7 +471,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Context: "/test", Version: "v1", ProjectId: projectID, - Upstream: api.Upstream{}, + Upstream: validUpstream(), Channels: &[]api.Channel{ { Request: api.ChannelRequest{ @@ -490,7 +490,7 @@ func TestValidateCreateAPIRequest(t *testing.T) { Context: "/test", Version: "v1", ProjectId: projectID, - Upstream: api.Upstream{}, + Upstream: validUpstream(), Operations: &[]api.Operation{ { Request: api.OperationRequest{ @@ -577,6 +577,9 @@ func TestApplyAPIUpdatesUpdatesPolicies(t *testing.T) { ProjectID: "11111111-1111-1111-1111-111111111111", Version: "v1", Configuration: model.RestAPIConfig{ + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "http://backend"}, + }, Policies: []model.Policy{ {Name: "legacy-policy", Version: "v1"}, }, @@ -799,7 +802,7 @@ func TestAPIServiceCreate_MissingSecretRef_Rejected(t *testing.T) { Context: "/test", Version: "v1", ProjectId: "11111111-1111-1111-1111-111111111111", - Upstream: api.Upstream{}, + Upstream: validUpstream(), Policies: &[]api.Policy{{Name: "set-headers", Version: "v1", Params: ¶ms}}, } diff --git a/platform-api/internal/service/upstream_validation_test.go b/platform-api/internal/service/upstream_validation_test.go new file mode 100644 index 000000000..9e5f2429d --- /dev/null +++ b/platform-api/internal/service/upstream_validation_test.go @@ -0,0 +1,510 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 service + +import ( + "database/sql" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/utils" + + _ "github.com/mattn/go-sqlite3" +) + +// validUpstream returns a minimal API-level upstream that passes validation. +func validUpstream() api.Upstream { + u := "http://backend" + return api.Upstream{Main: api.UpstreamDefinition{Url: &u}} +} + +// newAPIOperationUpstreamTarget constructs the anonymous ref-only target type +// generated for OperationUpstream.main and OperationUpstream.sandbox. +func newAPIOperationUpstreamTarget(ref string) *struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` +} { + return &struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` + }{Ref: ref} +} + +// TestValidateUpstreamRefs verifies that API-level and per-operation upstream refs must name a +// declared upstreamDefinition, that the pool itself is well-formed, and that duplicate +// definition names are rejected. +func TestValidateUpstreamRefs(t *testing.T) { + s := &APIService{} + mainURL := "http://main:8080" + validUp := api.Upstream{Main: api.UpstreamDefinition{Url: &mainURL}} + ru := func(name string) api.ReusableUpstream { + r := api.ReusableUpstream{Name: name} + r.Upstreams = append(r.Upstreams, struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{Url: "http://b:8080"}) + return r + } + defs := &[]api.ReusableUpstream{ru("alt-backend"), ru("foo")} + opWithMainRef := func(ref string) *[]api.Operation { + return &[]api.Operation{ + {Request: api.OperationRequest{ + Method: api.OperationRequestMethodGET, + Path: "/x", + Upstream: &api.OperationUpstream{Main: newAPIOperationUpstreamTarget(ref)}, + }}, + } + } + + t.Run("per-op main ref resolves", func(t *testing.T) { + if err := s.validateUpstreamRefs(defs, validUp, opWithMainRef("alt-backend")); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) + t.Run("per-op sandbox ref resolves", func(t *testing.T) { + ops := &[]api.Operation{ + {Request: api.OperationRequest{ + Method: api.OperationRequestMethodGET, + Path: "/x", + Upstream: &api.OperationUpstream{Sandbox: newAPIOperationUpstreamTarget("foo")}, + }}, + } + if err := s.validateUpstreamRefs(defs, validUp, ops); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) + t.Run("per-op ref unresolved", func(t *testing.T) { + if err := s.validateUpstreamRefs(defs, validUp, opWithMainRef("missing")); err == nil { + t.Error("expected error for undefined per-op ref") + } + }) + t.Run("API-level ref unresolved", func(t *testing.T) { + ref := "nope" + up := api.Upstream{Main: api.UpstreamDefinition{Ref: &ref}} + if err := s.validateUpstreamRefs(defs, up, nil); err == nil { + t.Error("expected error for undefined API-level ref") + } + }) + t.Run("API-level url and ref rejected", func(t *testing.T) { + urlValue := "http://main:8080" + ref := "alt-backend" + up := api.Upstream{Main: api.UpstreamDefinition{Url: &urlValue, Ref: &ref}} + err := s.validateUpstreamRefs(defs, up, nil) + if err == nil || !strings.Contains(err.Error(), "upstream.main") { + t.Fatalf("expected upstream.main url/ref error, got %v", err) + } + }) + t.Run("API-level missing url and ref rejected", func(t *testing.T) { + err := s.validateUpstreamRefs(defs, api.Upstream{}, nil) + if err == nil || !strings.Contains(err.Error(), "upstream.main") { + t.Fatalf("expected upstream.main missing target error, got %v", err) + } + }) + t.Run("API-level ref name contract enforced", func(t *testing.T) { + ref := "bad ref!" + up := api.Upstream{Main: api.UpstreamDefinition{Ref: &ref}} + err := s.validateUpstreamRefs(defs, up, nil) + if err == nil || !strings.Contains(err.Error(), "upstream.main.ref") { + t.Fatalf("expected upstream.main.ref name error, got %v", err) + } + }) + t.Run("API-level url with surrounding whitespace rejected", func(t *testing.T) { + // The gateway parses the original (untrimmed) URL, so the platform must too; + // validating a trimmed copy would persist a URL the gateway rejects at deploy. + padded := " http://main:8080 " + up := api.Upstream{Main: api.UpstreamDefinition{Url: &padded}} + if err := s.validateUpstreamRefs(nil, up, nil); err == nil { + t.Error("expected error for API-level url with surrounding whitespace") + } + }) + t.Run("duplicate definition names", func(t *testing.T) { + dup := &[]api.ReusableUpstream{ru("x"), ru("x")} + if err := s.validateUpstreamRefs(dup, validUp, nil); err == nil { + t.Error("expected error for duplicate definition names") + } + }) + t.Run("definition with zero upstreams rejected", func(t *testing.T) { + bare := &[]api.ReusableUpstream{{Name: "bare"}} + if err := s.validateUpstreamRefs(bare, validUp, nil); err == nil { + t.Error("expected error for definition with no upstreams") + } + }) + mkDef := func(name, u string) api.ReusableUpstream { + d := api.ReusableUpstream{Name: name} + d.Upstreams = append(d.Upstreams, struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{Url: u}) + return d + } + wantErr := func(t *testing.T, defs []api.ReusableUpstream, ops *[]api.Operation, what string) { + if err := s.validateUpstreamRefs(&defs, validUp, ops); err == nil { + t.Errorf("expected error for %s", what) + } + } + t.Run("non-first upstream with empty url rejected", func(t *testing.T) { + d := mkDef("multi", "http://b1:8080") + d.Upstreams = append(d.Upstreams, struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{Url: ""}) + wantErr(t, []api.ReusableUpstream{d}, nil, "empty url on a non-first upstream") + }) + t.Run("zero weight accepted", func(t *testing.T) { + zero := 0 + d := mkDef("w", "http://b:8080") + d.Upstreams[0].Weight = &zero + if err := s.validateUpstreamRefs(&[]api.ReusableUpstream{d}, validUp, nil); err != nil { + t.Errorf("zero weight should be valid (0..100), got %v", err) + } + }) + t.Run("weight over 100 rejected", func(t *testing.T) { + over := 105 + d := mkDef("w", "http://b:8080") + d.Upstreams[0].Weight = &over + wantErr(t, []api.ReusableUpstream{d}, nil, "weight > 100") + }) + t.Run("negative weight rejected", func(t *testing.T) { + neg := -10 + d := mkDef("w", "http://b:8080") + d.Upstreams[0].Weight = &neg + wantErr(t, []api.ReusableUpstream{d}, nil, "weight < 0") + }) + t.Run("weight of exactly 100 accepted", func(t *testing.T) { + hundred := 100 + d := mkDef("w", "http://b:8080") + d.Upstreams[0].Weight = &hundred + if err := s.validateUpstreamRefs(&[]api.ReusableUpstream{d}, validUp, nil); err != nil { + t.Errorf("weight 100 should be valid (0..100), got %v", err) + } + }) + t.Run("def name bad chars rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDef("bad name!", "http://b:8080")}, nil, "bad definition name") + }) + t.Run("def name too long rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDef(strings.Repeat("a", 101), "http://b:8080")}, nil, "definition name > 100") + }) + t.Run("def url with path rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDef("d", "http://b:8080/foo")}, nil, "url with path") + }) + t.Run("def url with query rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDef("d", "http://b:8080?debug=true")}, nil, "url with query") + }) + t.Run("def url with fragment rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDef("d", "http://b:8080#section")}, nil, "url with fragment") + }) + t.Run("def url bad scheme rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDef("d", "ftp://b:8080")}, nil, "url bad scheme") + }) + t.Run("def url no host rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDef("d", "http://")}, nil, "url no host") + }) + mkDefWithBasePath := func(basePath string) api.ReusableUpstream { + d := mkDef("d", "http://b:8080") + d.BasePath = &basePath + return d + } + t.Run("def basePath without leading slash rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDefWithBasePath("api/v2")}, nil, "basePath without leading '/'") + }) + t.Run("def basePath with trailing slash rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDefWithBasePath("/api/v2/")}, nil, "basePath with trailing '/'") + }) + t.Run("def basePath bare root slash rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDefWithBasePath("/")}, nil, "bare '/' basePath (root is expressed by omitting)") + }) + t.Run("def basePath with space rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDefWithBasePath("/api v2")}, nil, "basePath with a space") + }) + t.Run("def basePath with query rejected", func(t *testing.T) { + wantErr(t, []api.ReusableUpstream{mkDefWithBasePath("/api?x=1")}, nil, "basePath with a query string") + }) + t.Run("def valid basePath accepted", func(t *testing.T) { + if err := s.validateUpstreamRefs(&[]api.ReusableUpstream{mkDefWithBasePath("/api/v2")}, validUp, nil); err != nil { + t.Errorf("basePath /api/v2 should be valid, got %v", err) + } + }) + t.Run("def empty basePath treated as unset", func(t *testing.T) { + // omitempty drops an empty basePath from the deployment YAML, so the gateway + // never sees it; rejecting it here would be stricter than the deploy contract. + if err := s.validateUpstreamRefs(&[]api.ReusableUpstream{mkDefWithBasePath("")}, validUp, nil); err != nil { + t.Errorf("empty basePath should be treated as unset, got %v", err) + } + }) + t.Run("per-op empty ref rejected", func(t *testing.T) { + defs := []api.ReusableUpstream{mkDef("svc", "http://b:8080")} + ops := []api.Operation{{Request: api.OperationRequest{Method: api.OperationRequestMethodGET, Path: "/x", + Upstream: &api.OperationUpstream{Main: newAPIOperationUpstreamTarget("")}}}} + wantErr(t, defs, &ops, "empty per-op ref") + }) + t.Run("per-op ref name contract enforced", func(t *testing.T) { + defs := []api.ReusableUpstream{mkDef("svc", "http://b:8080")} + ops := []api.Operation{{Request: api.OperationRequest{Method: api.OperationRequestMethodGET, Path: "/x", + Upstream: &api.OperationUpstream{Main: newAPIOperationUpstreamTarget("bad ref!")}}}} + err := s.validateUpstreamRefs(&defs, validUp, &ops) + if err == nil || !strings.Contains(err.Error(), "operations[0].upstream.main.ref") { + t.Fatalf("expected indexed per-op ref error, got %v", err) + } + }) + t.Run("per-op empty wrapper rejected", func(t *testing.T) { + defs := []api.ReusableUpstream{mkDef("svc", "http://b:8080")} + ops := []api.Operation{{Request: api.OperationRequest{Method: api.OperationRequestMethodGET, Path: "/x", + Upstream: &api.OperationUpstream{}}}} + wantErr(t, defs, &ops, "empty per-op upstream wrapper") + }) + t.Run("invalid timeout.connect rejected", func(t *testing.T) { + bad := "5x" + d := ru("t") + d.Timeout = &api.UpstreamTimeout{Connect: &bad} + if err := s.validateUpstreamRefs(&[]api.ReusableUpstream{d}, validUp, nil); err == nil { + t.Error("expected error for invalid timeout.connect") + } + }) + t.Run("non-positive timeout.connect rejected", func(t *testing.T) { + for _, v := range []string{"0s", "-1s"} { + d := ru("t") + d.Timeout = &api.UpstreamTimeout{Connect: &v} + if err := s.validateUpstreamRefs(&[]api.ReusableUpstream{d}, validUp, nil); err == nil { + t.Errorf("expected error for non-positive timeout.connect %q", v) + } + } + }) + t.Run("valid timeout.connect accepted", func(t *testing.T) { + good := "5s" + d := ru("t") + d.Timeout = &api.UpstreamTimeout{Connect: &good} + if err := s.validateUpstreamRefs(&[]api.ReusableUpstream{d}, validUp, nil); err != nil { + t.Errorf("expected nil for valid timeout.connect, got %v", err) + } + }) + t.Run("blank timeout.connect accepted", func(t *testing.T) { + // The gateway trims and treats a blank or whitespace-only connect as unset, so the + // platform must accept it too rather than reject a config the gateway would deploy. + for _, v := range []string{"", " "} { + d := ru("t") + d.Timeout = &api.UpstreamTimeout{Connect: &v} + if err := s.validateUpstreamRefs(&[]api.ReusableUpstream{d}, validUp, nil); err != nil { + t.Errorf("expected nil for blank timeout.connect %q, got %v", v, err) + } + } + }) + t.Run("non-canonical timeout.connect unit rejected", func(t *testing.T) { + // time.ParseDuration accepts these, but the gateway only allows ms, s, m, h, so the + // control plane must reject them too or the API saves and then fails to deploy. + for _, v := range []string{"1h30m", "500ns", "500us"} { + d := ru("t") + d.Timeout = &api.UpstreamTimeout{Connect: &v} + if err := s.validateUpstreamRefs(&[]api.ReusableUpstream{d}, validUp, nil); err == nil { + t.Errorf("expected error for non-canonical timeout.connect unit %q", v) + } + } + }) + t.Run("direct URL without definitions is valid", func(t *testing.T) { + if err := s.validateUpstreamRefs(nil, validUp, nil); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) +} + +const upstreamITOrgUUID = "org-upstream-it" +const upstreamITProjectUUID = "proj-upstream-it" +const upstreamITProjectHandle = "upstream-it-proj" + +// setupUpstreamITEnv creates a real SQLite-backed APIService (real service and +// repositories, no mocks; collaborators these tests never reach are nil) plus a +// seeded organization and project. +func setupUpstreamITEnv(t *testing.T) *APIService { + t.Helper() + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "upstream-it.db") + sqlDB, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + if _, err := sqlDB.Exec("PRAGMA foreign_keys = ON"); err != nil { + t.Fatalf("enable foreign keys: %v", err) + } + db := &database.DB{DB: sqlDB} + t.Cleanup(func() { sqlDB.Close() }) + + schemaPath := filepath.Join("..", "database", "schema.sqlite.sql") + schema, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("read schema: %v", err) + } + if _, err = db.Exec(string(schema)); err != nil { + t.Fatalf("apply schema: %v", err) + } + + if _, err = db.Exec(`INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) + VALUES (?, 'upstream-it-org', 'Upstream IT Org', 'default', 'idp-ref', datetime('now'), datetime('now'))`, upstreamITOrgUUID); err != nil { + t.Fatalf("insert org: %v", err) + } + if _, err = db.Exec(`INSERT INTO projects (uuid, handle, display_name, description, organization_uuid, created_at, updated_at) + VALUES (?, ?, 'Upstream IT Project', '', ?, datetime('now'), datetime('now'))`, upstreamITProjectUUID, upstreamITProjectHandle, upstreamITOrgUUID); err != nil { + t.Fatalf("insert project: %v", err) + } + + identity := NewIdentityService(repository.NewUserIdentityMappingRepo(db)) + apiRepo := repository.NewAPIRepo(db) + projectRepo := repository.NewProjectRepo(db) + auditRepo := repository.NewAuditRepo(db) + + return NewAPIService(apiRepo, projectRepo, nil, nil, nil, nil, nil, nil, &utils.APIUtil{}, slog.Default(), auditRepo, identity) +} + +// perOpReusableUpstream builds a pool entry with a single backend. +func perOpReusableUpstream(name, basePath, backendURL string) api.ReusableUpstream { + r := api.ReusableUpstream{Name: name} + if basePath != "" { + r.BasePath = &basePath + } + r.Upstreams = append(r.Upstreams, struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{Url: backendURL}) + return r +} + +// perOpCreateRequest builds a create request with a pool and a per-operation ref on /whoami. +func perOpCreateRequest() *api.CreateRESTAPIRequest { + handle := "perop-svc-it-api" + mainURL := "http://default-backend:8080" + defs := []api.ReusableUpstream{perOpReusableUpstream("alt-backend", "/alternate", "http://alt-backend:9090")} + ops := []api.Operation{ + {Request: api.OperationRequest{ + Method: api.OperationRequestMethodGET, Path: "/whoami", + Upstream: &api.OperationUpstream{Main: newAPIOperationUpstreamTarget("alt-backend")}, + }}, + {Request: api.OperationRequest{Method: api.OperationRequestMethodGET, Path: "/ping"}}, + } + return &api.CreateRESTAPIRequest{ + DisplayName: "PerOp Svc IT API", + Id: &handle, + Context: "/perop-svc-it", + Version: "1.0.0", + ProjectId: upstreamITProjectHandle, + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: &mainURL}}, + UpstreamDefinitions: &defs, + Operations: &ops, + } +} + +// assertPoolAndPerOp checks that a RESTAPI carries the reusable pool and that the per-op +// ref is attached to /whoami (and not inherited by /ping). +func assertPoolAndPerOp(t *testing.T, where string, a *api.RESTAPI) { + t.Helper() + if a.UpstreamDefinitions == nil || len(*a.UpstreamDefinitions) != 1 { + t.Fatalf("%s: want 1 upstreamDefinition, got %+v", where, a.UpstreamDefinitions) + } + def := (*a.UpstreamDefinitions)[0] + if def.Name != "alt-backend" { + t.Errorf("%s: pool name mismatch: %+v", where, def) + } + if def.BasePath == nil || *def.BasePath != "/alternate" { + t.Errorf("%s: pool basePath mismatch: %+v", where, def.BasePath) + } + if len(def.Upstreams) != 1 || def.Upstreams[0].Url != "http://alt-backend:9090" { + t.Errorf("%s: pool backends mismatch: %+v", where, def.Upstreams) + } + if a.Operations == nil || len(*a.Operations) != 2 { + t.Fatalf("%s: want 2 operations, got %+v", where, a.Operations) + } + var whoami, ping *api.Operation + for i := range *a.Operations { + op := &(*a.Operations)[i] + switch op.Request.Path { + case "/whoami": + whoami = op + case "/ping": + ping = op + } + } + if whoami == nil || whoami.Request.Upstream == nil || whoami.Request.Upstream.Main == nil || + whoami.Request.Upstream.Main.Ref != "alt-backend" { + t.Errorf("%s: /whoami per-op ref missing or wrong: %+v", where, whoami) + } + if ping == nil || ping.Request.Upstream != nil { + t.Errorf("%s: /ping must not inherit a per-op upstream: %+v", where, ping) + } +} + +// TestAPIService_CreatePersistAndReadPerOpUpstream proves the pool and per-operation ref +// survive create and read through the real service and repositories. +func TestAPIService_CreatePersistAndReadPerOpUpstream(t *testing.T) { + svc := setupUpstreamITEnv(t) + + created, err := svc.CreateAPI(perOpCreateRequest(), upstreamITOrgUUID, "tester") + if err != nil { + t.Fatalf("CreateAPI: %v", err) + } + assertPoolAndPerOp(t, "create", created) + + read, err := svc.GetAPIByHandle("perop-svc-it-api", upstreamITOrgUUID) + if err != nil { + t.Fatalf("GetAPIByHandle: %v", err) + } + assertPoolAndPerOp(t, "read", read) +} + +// TestAPIService_CreateRejectsUnresolvedPerOpRef ensures a per-operation ref that does +// not resolve to a declared upstreamDefinition is rejected as a validation failure, +// not silently persisted. +func TestAPIService_CreateRejectsUnresolvedPerOpRef(t *testing.T) { + svc := setupUpstreamITEnv(t) + + req := perOpCreateRequest() + badHandle := "bad-ref-api" + req.Id = &badHandle + req.Context = "/perop-svc-it-bad" + req.UpstreamDefinitions = nil // drop the pool so the per-op ref no longer resolves + + if _, err := svc.CreateAPI(req, upstreamITOrgUUID, "tester"); err == nil { + t.Fatal("expected error for unresolved per-op ref") + } else if !apperror.ValidationFailed.Is(err) { + t.Errorf("want ValidationFailed, got %v", err) + } +} + +// TestAPIService_UpdateRejectsPoolRemovalWithDanglingRef ensures an update that replaces the +// pool cannot orphan a stored per-operation ref: validation runs on the merged result, so +// removing the referenced definition is rejected instead of persisting a config the gateway +// would refuse to deploy. +func TestAPIService_UpdateRejectsPoolRemovalWithDanglingRef(t *testing.T) { + svc := setupUpstreamITEnv(t) + + if _, err := svc.CreateAPI(perOpCreateRequest(), upstreamITOrgUUID, "tester"); err != nil { + t.Fatalf("CreateAPI: %v", err) + } + + // Replace the pool with one that no longer contains "alt-backend", which the stored + // /whoami operation still references. + newPool := []api.ReusableUpstream{perOpReusableUpstream("other-backend", "/other", "http://other:9090")} + _, err := svc.UpdateAPIByHandle("perop-svc-it-api", &api.RESTAPI{UpstreamDefinitions: &newPool}, upstreamITOrgUUID, "tester") + if err == nil { + t.Fatal("expected error for pool removal with dangling per-op ref") + } else if !apperror.ValidationFailed.Is(err) { + t.Errorf("want ValidationFailed, got %v", err) + } +} diff --git a/platform-api/internal/utils/api.go b/platform-api/internal/utils/api.go index 3c14ce198..e0628b237 100644 --- a/platform-api/internal/utils/api.go +++ b/platform-api/internal/utils/api.go @@ -82,14 +82,15 @@ func (u *APIUtil) RESTAPIToModel(restAPI *api.RESTAPI, orgID string) *model.API LifeCycleStatus: lifeCycleStatus, Channels: u.ChannelsAPIToModel(restAPI.Channels), Configuration: model.RestAPIConfig{ - Name: restAPI.DisplayName, - Version: restAPI.Version, - Context: &restAPI.Context, - Transport: stringSliceValue(restAPI.Transport), - Upstream: *u.UpstreamConfigAPIToModel(&restAPI.Upstream), - Policies: u.PoliciesAPIToModel(restAPI.Policies), - Operations: u.OperationsAPIToModel(restAPI.Operations), - SubscriptionPlans: stringSliceValue(restAPI.SubscriptionPlans), + Name: restAPI.DisplayName, + Version: restAPI.Version, + Context: &restAPI.Context, + Transport: stringSliceValue(restAPI.Transport), + Upstream: *u.UpstreamConfigAPIToModel(&restAPI.Upstream), + UpstreamDefinitions: u.UpstreamDefinitionsAPIToModel(restAPI.UpstreamDefinitions), + Policies: u.PoliciesAPIToModel(restAPI.Policies), + Operations: u.OperationsAPIToModel(restAPI.Operations), + SubscriptionPlans: stringSliceValue(restAPI.SubscriptionPlans), }, Origin: constants.OriginCP, } @@ -119,25 +120,26 @@ func (u *APIUtil) ModelToRESTAPI(modelAPI *model.API, projectHandle string) (*ap } return &api.RESTAPI{ - Channels: u.ChannelsModelToAPI(modelAPI.Channels), - Context: defaultStringPtr(modelAPI.Configuration.Context), - CreatedAt: TimePtrIfNotZero(modelAPI.CreatedAt), - CreatedBy: StringPtrIfNotEmpty(modelAPI.CreatedBy), - Description: StringPtrIfNotEmpty(modelAPI.Description), - Id: StringPtrIfNotEmpty(modelAPI.Handle), - Kind: StringPtrIfNotEmpty(modelAPI.Kind), - LifeCycleStatus: status, - DisplayName: modelAPI.Name, - Operations: u.OperationsModelToAPI(modelAPI.Configuration.Operations), - Policies: u.PoliciesModelToAPI(modelAPI.Configuration.Policies), - ProjectId: projectHandle, - ReadOnly: BoolPtr(modelAPI.Origin == constants.OriginDP), - SubscriptionPlans: stringSlicePtr(modelAPI.Configuration.SubscriptionPlans), - Transport: stringSlicePtr(modelAPI.Configuration.Transport), - UpdatedAt: TimePtrIfNotZero(modelAPI.UpdatedAt), - UpdatedBy: StringPtrIfNotEmpty(modelAPI.UpdatedBy), - Upstream: u.UpstreamConfigModelToAPI(&modelAPI.Configuration.Upstream), - Version: modelAPI.Version, + Channels: u.ChannelsModelToAPI(modelAPI.Channels), + Context: defaultStringPtr(modelAPI.Configuration.Context), + CreatedAt: TimePtrIfNotZero(modelAPI.CreatedAt), + CreatedBy: StringPtrIfNotEmpty(modelAPI.CreatedBy), + Description: StringPtrIfNotEmpty(modelAPI.Description), + Id: StringPtrIfNotEmpty(modelAPI.Handle), + Kind: StringPtrIfNotEmpty(modelAPI.Kind), + LifeCycleStatus: status, + DisplayName: modelAPI.Name, + Operations: u.OperationsModelToAPI(modelAPI.Configuration.Operations), + Policies: u.PoliciesModelToAPI(modelAPI.Configuration.Policies), + ProjectId: projectHandle, + ReadOnly: BoolPtr(modelAPI.Origin == constants.OriginDP), + SubscriptionPlans: stringSlicePtr(modelAPI.Configuration.SubscriptionPlans), + Transport: stringSlicePtr(modelAPI.Configuration.Transport), + UpdatedAt: TimePtrIfNotZero(modelAPI.UpdatedAt), + UpdatedBy: StringPtrIfNotEmpty(modelAPI.UpdatedBy), + Upstream: u.UpstreamConfigModelToAPI(&modelAPI.Configuration.Upstream), + UpstreamDefinitions: u.UpstreamDefinitionsModelToAPI(modelAPI.Configuration.UpstreamDefinitions), + Version: modelAPI.Version, }, nil } @@ -241,6 +243,7 @@ func (u *APIUtil) OperationRequestAPIToModel(req *api.OperationRequest) *model.O Method: string(req.Method), Path: req.Path, Policies: u.PoliciesAPIToModel(req.Policies), + Upstream: u.operationUpstreamAPIToModel(req.Upstream), } } @@ -318,6 +321,43 @@ func (u *APIUtil) upstreamAuthToModel(auth *api.UpstreamAuth) *model.UpstreamAut return modelAuth } +func (u *APIUtil) UpstreamDefinitionsAPIToModel(definitions *[]api.ReusableUpstream) []model.ReusableUpstream { + if definitions == nil { + return nil + } + models := make([]model.ReusableUpstream, 0, len(*definitions)) + for _, definition := range *definitions { + m := model.ReusableUpstream{ + Name: definition.Name, + BasePath: defaultStringPtr(definition.BasePath), + } + if definition.Timeout != nil && definition.Timeout.Connect != nil { + m.Timeout = &model.UpstreamTimeout{Connect: *definition.Timeout.Connect} + } + targets := make([]model.UpstreamTarget, 0, len(definition.Upstreams)) + for _, target := range definition.Upstreams { + targets = append(targets, model.UpstreamTarget{URL: target.Url, Weight: target.Weight}) + } + m.Upstreams = targets + models = append(models, m) + } + return models +} + +func (u *APIUtil) operationUpstreamAPIToModel(upstream *api.OperationUpstream) *model.OperationUpstream { + if upstream == nil { + return nil + } + modelUpstream := &model.OperationUpstream{} + if upstream.Main != nil { + modelUpstream.Main = &model.OperationUpstreamRef{Ref: upstream.Main.Ref} + } + if upstream.Sandbox != nil { + modelUpstream.Sandbox = &model.OperationUpstreamRef{Ref: upstream.Sandbox.Ref} + } + return modelUpstream +} + // Model to API conversion helpers func (u *APIUtil) OperationsModelToAPI(models []model.Operation) *[]api.Operation { @@ -382,6 +422,7 @@ func (u *APIUtil) OperationRequestModelToAPI(modelReq *model.OperationRequest) * Method: api.OperationRequestMethod(modelReq.Method), Path: modelReq.Path, Policies: u.PoliciesModelToAPI(modelReq.Policies), + Upstream: u.operationUpstreamModelToAPI(modelReq.Upstream), } } @@ -467,6 +508,63 @@ func (u *APIUtil) upstreamAuthToAPI(auth *model.UpstreamAuth) *api.UpstreamAuth return apiAuth } +func (u *APIUtil) UpstreamDefinitionsModelToAPI(models []model.ReusableUpstream) *[]api.ReusableUpstream { + if len(models) == 0 { + return nil + } + definitions := make([]api.ReusableUpstream, 0, len(models)) + for _, m := range models { + definition := api.ReusableUpstream{ + Name: m.Name, + BasePath: StringPtrIfNotEmpty(m.BasePath), + } + if m.Timeout != nil { + definition.Timeout = &api.UpstreamTimeout{Connect: StringPtrIfNotEmpty(m.Timeout.Connect)} + } + for _, target := range m.Upstreams { + definition.Upstreams = append(definition.Upstreams, newAPIUpstreamBackend(target.URL, target.Weight)) + } + definitions = append(definitions, definition) + } + return &definitions +} + +func (u *APIUtil) operationUpstreamModelToAPI(modelUpstream *model.OperationUpstream) *api.OperationUpstream { + if modelUpstream == nil { + return nil + } + upstream := &api.OperationUpstream{} + if modelUpstream.Main != nil { + upstream.Main = newAPIOperationUpstreamTarget(modelUpstream.Main.Ref) + } + if modelUpstream.Sandbox != nil { + upstream.Sandbox = newAPIOperationUpstreamTarget(modelUpstream.Sandbox.Ref) + } + return upstream +} + +// newAPIOperationUpstreamTarget constructs the anonymous ref-only target type +// generated for OperationUpstream.main and OperationUpstream.sandbox. +func newAPIOperationUpstreamTarget(ref string) *struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` +} { + return &struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` + }{Ref: ref} +} + +// newAPIUpstreamBackend constructs the anonymous backend entry type generated for +// ReusableUpstream.upstreams. +func newAPIUpstreamBackend(backendURL string, weight *int) struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` +} { + return struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{Url: backendURL, Weight: weight} +} + // BuildAPIDeploymentYAML builds the deployment YAML struct from API model without marshalling func (u *APIUtil) BuildAPIDeploymentYAML(apiModel *model.API) (*dto.APIDeploymentYAML, error) { operationList := make([]api.OperationRequest, 0) @@ -475,6 +573,7 @@ func (u *APIUtil) BuildAPIDeploymentYAML(apiModel *model.API) (*dto.APIDeploymen Method: api.OperationRequestMethod(op.Request.Method), Path: op.Request.Path, Policies: u.PoliciesModelToAPI(op.Request.Policies), + Upstream: u.operationUpstreamModelToAPI(op.Request.Upstream), }) } channelList := make([]api.ChannelRequest, 0) @@ -510,6 +609,26 @@ func (u *APIUtil) BuildAPIDeploymentYAML(apiModel *model.API) (*dto.APIDeploymen } } + // Convert reusable upstream definitions (the named pool that API-level and + // operation-level upstream refs resolve against on the gateway). + var upstreamDefsYAML []dto.UpstreamDefinitionYAML + for _, def := range apiModel.Configuration.UpstreamDefinitions { + defYAML := dto.UpstreamDefinitionYAML{ + Name: def.Name, + BasePath: def.BasePath, + } + if def.Timeout != nil { + defYAML.Timeout = &dto.UpstreamTimeoutYAML{Connect: def.Timeout.Connect} + } + for _, b := range def.Upstreams { + defYAML.Upstreams = append(defYAML.Upstreams, dto.UpstreamBackendYAML{ + URL: b.URL, + Weight: b.Weight, + }) + } + upstreamDefsYAML = append(upstreamDefsYAML, defYAML) + } + apiYAMLData := dto.APIYAMLData{} apiYAMLData.DisplayName = apiModel.Name apiYAMLData.Version = apiModel.Version @@ -521,6 +640,7 @@ func (u *APIUtil) BuildAPIDeploymentYAML(apiModel *model.API) (*dto.APIDeploymen switch apiModel.Kind { case constants.RestApi: apiYAMLData.Upstream = upstreamYAML + apiYAMLData.UpstreamDefinitions = upstreamDefsYAML apiYAMLData.Operations = operationList case constants.WebSubApi: apiYAMLData.Channels = channelList diff --git a/platform-api/internal/utils/api_upstream_test.go b/platform-api/internal/utils/api_upstream_test.go new file mode 100644 index 000000000..387e3826d --- /dev/null +++ b/platform-api/internal/utils/api_upstream_test.go @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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 utils + +import ( + "strings" + "testing" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + + "gopkg.in/yaml.v3" +) + +// TestBuildAPIDeploymentYAML_EmitsUpstreamDefinitions verifies the reusable pool is emitted +// into spec.upstreamDefinitions with name, basePath, timeout, and weighted backends. +func TestBuildAPIDeploymentYAML_EmitsUpstreamDefinitions(t *testing.T) { + util := &APIUtil{} + ctx := "/test" + weight := 80 + apiModel := &model.API{ + Name: "Pool API", + Handle: "pool-api", + Version: "v1.0", + Kind: constants.RestApi, + Configuration: model.RestAPIConfig{ + Context: &ctx, + Upstream: model.UpstreamConfig{Main: &model.UpstreamEndpoint{URL: "http://main:8080"}}, + UpstreamDefinitions: []model.ReusableUpstream{ + { + Name: "alt-backend", + BasePath: "/api/v2", + Timeout: &model.UpstreamTimeout{Connect: "5s"}, + Upstreams: []model.UpstreamTarget{{URL: "http://alt:9090", Weight: &weight}}, + }, + }, + Operations: []model.Operation{ + {Request: &model.OperationRequest{Method: "GET", Path: "/x"}}, + }, + }, + } + + d, err := util.BuildAPIDeploymentYAML(apiModel) + if err != nil { + t.Fatalf("BuildAPIDeploymentYAML() error = %v", err) + } + if len(d.Spec.UpstreamDefinitions) != 1 { + t.Fatalf("want 1 upstreamDefinition, got %+v", d.Spec.UpstreamDefinitions) + } + got := d.Spec.UpstreamDefinitions[0] + if got.Name != "alt-backend" || got.BasePath != "/api/v2" { + t.Errorf("definition mismatch: want name=%q basePath=%q, got %+v", "alt-backend", "/api/v2", got) + } + if got.Timeout == nil || got.Timeout.Connect != "5s" { + t.Errorf("Timeout = %+v, want connect 5s", got.Timeout) + } + if len(got.Upstreams) != 1 || got.Upstreams[0].URL != "http://alt:9090" || + got.Upstreams[0].Weight == nil || *got.Upstreams[0].Weight != 80 { + t.Errorf("Upstreams = %+v, want url http://alt:9090 weight 80", got.Upstreams) + } + + out, err := yaml.Marshal(d) + if err != nil { + t.Fatalf("yaml.Marshal error = %v", err) + } + if !strings.Contains(string(out), "upstreamDefinitions:") || !strings.Contains(string(out), "alt-backend") { + t.Errorf("emitted YAML missing upstreamDefinitions/alt-backend:\n%s", out) + } +} + +// TestBuildAPIDeploymentYAML_EmitsPerOpUpstreamRef verifies a per-operation upstream ref is +// emitted into the operation in the deployment YAML, and only for the overridden operation. +func TestBuildAPIDeploymentYAML_EmitsPerOpUpstreamRef(t *testing.T) { + util := &APIUtil{} + ctx := "/test" + apiModel := &model.API{ + Name: "PerOp API", + Handle: "perop-api", + Version: "v1.0", + Kind: constants.RestApi, + Configuration: model.RestAPIConfig{ + Context: &ctx, + Upstream: model.UpstreamConfig{Main: &model.UpstreamEndpoint{URL: "http://main:8080"}}, + UpstreamDefinitions: []model.ReusableUpstream{ + {Name: "alt-backend", Upstreams: []model.UpstreamTarget{{URL: "http://alt:9090"}}}, + }, + Operations: []model.Operation{ + {Request: &model.OperationRequest{ + Method: "GET", Path: "/whoami", + Upstream: &model.OperationUpstream{Main: &model.OperationUpstreamRef{Ref: "alt-backend"}}, + }}, + {Request: &model.OperationRequest{Method: "GET", Path: "/ping"}}, + }, + }, + } + + d, err := util.BuildAPIDeploymentYAML(apiModel) + if err != nil { + t.Fatalf("BuildAPIDeploymentYAML() error = %v", err) + } + if len(d.Spec.Operations) != 2 { + t.Fatalf("want 2 operations, got %+v", d.Spec.Operations) + } + whoami := d.Spec.Operations[0] + if whoami.Upstream == nil || whoami.Upstream.Main == nil || whoami.Upstream.Main.Ref != "alt-backend" { + t.Errorf("/whoami per-op ref missing or wrong: %+v", whoami.Upstream) + } + ping := d.Spec.Operations[1] + if ping.Upstream != nil { + t.Errorf("/ping must not carry a per-op upstream: %+v", ping.Upstream) + } + + out, err := yaml.Marshal(d) + if err != nil { + t.Fatalf("yaml.Marshal error = %v", err) + } + if !strings.Contains(string(out), "ref: alt-backend") { + t.Errorf("emitted YAML missing per-op ref:\n%s", out) + } +} + +// TestBuildAPIDeploymentYAML_WebSubOmitsUpstreamDefinitions ensures the pool is emitted only +// for REST APIs: a WebSub deployment must not carry upstreamDefinitions even if the model has them. +func TestBuildAPIDeploymentYAML_WebSubOmitsUpstreamDefinitions(t *testing.T) { + util := &APIUtil{} + ctx := "/events" + apiModel := &model.API{ + Name: "WebSub API", + Handle: "websub-api", + Version: "v1.0", + Kind: constants.WebSubApi, + Configuration: model.RestAPIConfig{ + Context: &ctx, + UpstreamDefinitions: []model.ReusableUpstream{ + {Name: "alt-backend", Upstreams: []model.UpstreamTarget{{URL: "http://alt:9090"}}}, + }, + }, + } + + d, err := util.BuildAPIDeploymentYAML(apiModel) + if err != nil { + t.Fatalf("BuildAPIDeploymentYAML() error = %v", err) + } + if len(d.Spec.UpstreamDefinitions) != 0 { + t.Errorf("WebSub deployment must not emit upstreamDefinitions, got %+v", d.Spec.UpstreamDefinitions) + } + if d.Spec.Upstream != nil { + t.Errorf("WebSub deployment must not emit upstream, got %+v", d.Spec.Upstream) + } +} + +// TestUpstreamDefinitions_RoundTripThroughModel verifies the pool and per-operation refs +// survive RESTAPIToModel and ModelToRESTAPI without loss. +func TestUpstreamDefinitions_RoundTripThroughModel(t *testing.T) { + util := &APIUtil{} + basePath := "/api/v2" + connect := "5s" + weight := 80 + pool := []api.ReusableUpstream{{ + Name: "alt-backend", + BasePath: &basePath, + Timeout: &api.UpstreamTimeout{Connect: &connect}, + }} + pool[0].Upstreams = append(pool[0].Upstreams, struct { + Url string `json:"url" yaml:"url"` + Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` + }{Url: "http://alt:9090", Weight: &weight}) + + mainURL := "http://main:8080" + ops := []api.Operation{{Request: api.OperationRequest{ + Method: api.OperationRequestMethodGET, Path: "/whoami", + Upstream: &api.OperationUpstream{Main: &struct { + Ref api.UpstreamReference `json:"ref" yaml:"ref"` + }{Ref: "alt-backend"}}, + }}} + + rest := &api.RESTAPI{ + DisplayName: "RoundTrip API", + Context: "/rt", + Version: "v1", + ProjectId: "proj-handle", + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: &mainURL}}, + UpstreamDefinitions: &pool, + Operations: &ops, + } + + m := util.RESTAPIToModel(rest, "org-1") + if len(m.Configuration.UpstreamDefinitions) != 1 { + t.Fatalf("model pool missing: %+v", m.Configuration.UpstreamDefinitions) + } + back, err := util.ModelToRESTAPI(m, "proj-handle") + if err != nil { + t.Fatalf("ModelToRESTAPI: %v", err) + } + if back.UpstreamDefinitions == nil || len(*back.UpstreamDefinitions) != 1 { + t.Fatalf("round-trip pool missing: %+v", back.UpstreamDefinitions) + } + def := (*back.UpstreamDefinitions)[0] + if def.Name != "alt-backend" || def.BasePath == nil || *def.BasePath != "/api/v2" || + def.Timeout == nil || def.Timeout.Connect == nil || *def.Timeout.Connect != "5s" || + len(def.Upstreams) != 1 || def.Upstreams[0].Url != "http://alt:9090" || + def.Upstreams[0].Weight == nil || *def.Upstreams[0].Weight != 80 { + t.Errorf("round-trip pool mismatch: %+v", def) + } + backOps := *back.Operations + if len(backOps) != 1 || backOps[0].Request.Upstream == nil || backOps[0].Request.Upstream.Main == nil || + backOps[0].Request.Upstream.Main.Ref != "alt-backend" { + t.Errorf("round-trip per-op ref mismatch: %+v", backOps) + } +} diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index fb2196d49..fce0109fe 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -5406,6 +5406,11 @@ components: example: false upstream: $ref: "#/components/schemas/Upstream" + upstreamDefinitions: + type: array + description: List of reusable named upstream definitions, referenced by `ref` from both API-level and operation-level upstreams. + items: + $ref: '#/components/schemas/ReusableUpstream' lifeCycleStatus: type: string description: Current lifecycle status of the API @@ -5557,6 +5562,8 @@ components: description: List of policies to be applied on the operation items: $ref: "#/components/schemas/Policy" + upstream: + $ref: "#/components/schemas/OperationUpstream" ChannelRequest: title: Channel Request @@ -6706,8 +6713,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' auth: $ref: '#/components/schemas/UpstreamAuth' @@ -6731,6 +6737,89 @@ components: description: Authentication value (API key, Bearer token, or Base64 encoded credentials for basic auth) example: my-api-key-value + OperationUpstream: + title: Operation Upstream + type: object + additionalProperties: false + minProperties: 1 + description: Per-operation upstream override. Each sub-field must reference a named entry in upstreamDefinitions. Missing sub-fields fall back to the API-level upstream. At least one of main or sandbox must be set. + 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' + + UpstreamReference: + title: Upstream Reference + type: string + description: Name of a ReusableUpstream entry in the API's upstreamDefinitions pool. Used by both API-level and operation-level upstream refs. + minLength: 1 + maxLength: 100 + pattern: '^[a-zA-Z0-9\-_]+$' + example: my-upstream-1 + + ReusableUpstream: + title: Reusable Upstream + type: object + required: + - name + - upstreams + description: A reusable named upstream definition. Referenced by name from API-level and operation-level upstream refs. + properties: + name: + $ref: '#/components/schemas/UpstreamReference' + basePath: + type: string + description: Base path prefix prepended to all requests routed to this upstream (e.g., /api/v2) + pattern: '^/[a-zA-Z0-9\-._~!$&''()*+,;=:@%/]*[^/]$' + example: /api/v2 + timeout: + $ref: '#/components/schemas/UpstreamTimeout' + upstreams: + type: array + description: List of backend targets with optional weights for load balancing + minItems: 1 + items: + type: object + additionalProperties: false + required: + - url + properties: + url: + type: string + format: uri + description: Backend URL (host and port only; path comes from basePath) + example: http://prod-backend-1:5000 + weight: + type: integer + description: Relative weight for load balancing across the definition's targets; when omitted, the gateway applies its default endpoint weight + minimum: 0 + maximum: 100 + example: 80 + + UpstreamTimeout: + title: Upstream Timeout + type: object + description: Timeout configuration for upstream requests + properties: + connect: + type: string + description: Connection timeout duration (e.g., "5s", "500ms") + pattern: '^\d+(\.\d+)?(ms|s|m|h)$' + example: 5s + ExtractionIdentifier: type: object required: