From 8058f3e5deaedbbe55c082f7019e317c637b1164 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Sun, 12 Jul 2026 10:07:51 +0530 Subject: [PATCH 1/5] feat(platform-api): add upstreamDefinitions pool and per-operation upstream routing --- platform-api/api/generated.go | 59 ++- platform-api/internal/dto/api.go | 41 +- platform-api/internal/model/api.go | 24 +- platform-api/internal/model/upstream.go | 30 ++ .../internal/repository/api_upstream_test.go | 97 ++++ platform-api/internal/service/api.go | 223 +++++++- platform-api/internal/service/api_test.go | 37 +- .../service/upstream_validation_test.go | 475 ++++++++++++++++++ platform-api/internal/utils/api.go | 174 ++++++- .../internal/utils/api_upstream_test.go | 226 +++++++++ platform-api/resources/openapi.yaml | 92 +++- 11 files changed, 1392 insertions(+), 86 deletions(-) create mode 100644 platform-api/internal/repository/api_upstream_test.go create mode 100644 platform-api/internal/service/upstream_validation_test.go create mode 100644 platform-api/internal/utils/api_upstream_test.go diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 3748ae810f..7d4a554243 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 Weight for load balancing (optional, default 100) + 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 77f56366d3..1bee2c35a4 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,30 @@ type UpstreamTarget struct { Ref string `yaml:"ref,omitempty"` } +// UpstreamDefinitionYAML represents a reusable named upstream definition in the +// deployment YAML (the API-level upstreamDefinitions array the gateway resolves +// refs against). +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/model/api.go b/platform-api/internal/model/api.go index 64d473962f..00c56a684d 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 258a05d322..a80323ec4d 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 0000000000..a027275c02 --- /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 3c1e5248f6..608ad8ef45 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 that every upstream ref (API-level + per-operation) resolves to a declared upstreamDefinition + if err := s.validateUpstreamRefs(req.UpstreamDefinitions, req.Upstream, req.Operations); err != nil { + return apperror.ValidationFailed.Wrap(err, err.Error()) + } + return nil } @@ -776,6 +783,182 @@ 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)$`) + +// 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, 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: 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 +1001,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 +1225,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 e74b70e333..0904ed8a33 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 0000000000..4f98562617 --- /dev/null +++ b/platform-api/internal/service/upstream_validation_test.go @@ -0,0 +1,475 @@ +/* + * 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") + }) + 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 (wired as server.go +// wires it in production) 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) + } + sqlDB.Exec("PRAGMA foreign_keys = ON") + 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 3c14ce1982..e0628b2370 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 0000000000..387e3826d8 --- /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 fb2196d49c..8d6cbab65c 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,88 @@ 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) + 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: Weight for load balancing (optional, default 100) + 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: From 408ae7d95ba408de104a3069ae8e1bd23be9d04d Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Thu, 16 Jul 2026 09:12:00 +0530 Subject: [PATCH 2/5] feat(platform-api): validate upstreamDefinitions basePath against the gateway contract Mirrors the gateway validator's basePath rule (must start with '/', must not end with '/'; empty treated as unset) in validateUpstreamRefs and the published OpenAPI pattern, so a stored API can no longer fail at deploy time. Adds handler integration tests pinning the HTTP 400 mapping for invalid upstream configuration on create and update. --- .../handler/api_upstream_integration_test.go | 185 ++++++++++++++++++ platform-api/internal/service/api.go | 15 +- .../service/upstream_validation_test.go | 32 +++ platform-api/resources/openapi.yaml | 1 + 4 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 platform-api/internal/handler/api_upstream_integration_test.go 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 0000000000..2cfdabed1e --- /dev/null +++ b/platform-api/internal/handler/api_upstream_integration_test.go @@ -0,0 +1,185 @@ +/* + * 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) + } + sqlDB.Exec("PRAGMA foreign_keys = ON") + 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 handler's mapping of +// ErrInvalidUpstreamDefinition 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/service/api.go b/platform-api/internal/service/api.go index 608ad8ef45..b955aa41d0 100644 --- a/platform-api/internal/service/api.go +++ b/platform-api/internal/service/api.go @@ -794,6 +794,11 @@ var upstreamRefNameRe = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`) // 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 == "" { @@ -857,8 +862,8 @@ func validateAPIUpstreamEndpoint(endpoint api.UpstreamDefinition, field string, } // validateUpstreamRefs ensures upstreamDefinitions are well-formed (unique, legal name, valid -// host-only url, in-range weight, positive timeout), validates each API-level url-or-ref union, -// and ensures every API-level and per-operation ref resolves. +// 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 { @@ -903,6 +908,12 @@ func (s *APIService) validateUpstreamRefs(upstreamDefs *[]api.ReusableUpstream, 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. diff --git a/platform-api/internal/service/upstream_validation_test.go b/platform-api/internal/service/upstream_validation_test.go index 4f98562617..3de51901d5 100644 --- a/platform-api/internal/service/upstream_validation_test.go +++ b/platform-api/internal/service/upstream_validation_test.go @@ -219,6 +219,38 @@ func TestValidateUpstreamRefs(t *testing.T) { 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", diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 8d6cbab65c..fbdb231fb0 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -6783,6 +6783,7 @@ components: 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' From a932f98a7c2c09e8705733247021ed932b7cd760 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Thu, 16 Jul 2026 11:48:53 +0530 Subject: [PATCH 3/5] fix(platform-api): correct the upstream weight description The previous wording claimed an omitted weight defaults to 100. An omitted weight is not serialized, and the gateway's load balancer applies its own default endpoint weight, so the description now states that instead of a fixed value. --- platform-api/api/generated.go | 2 +- platform-api/resources/openapi.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 7d4a554243..55b4e4ac8d 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -2268,7 +2268,7 @@ type ReusableUpstream struct { // Url Backend URL (host and port only; path comes from basePath) Url string `json:"url" yaml:"url"` - // Weight Weight for load balancing (optional, default 100) + // 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"` } diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index fbdb231fb0..fce0109feb 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -6804,7 +6804,7 @@ components: example: http://prod-backend-1:5000 weight: type: integer - description: Weight for load balancing (optional, default 100) + 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 From 40a652843a98abb61cf9d826dc88e902fb693865 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Fri, 17 Jul 2026 07:30:26 +0530 Subject: [PATCH 4/5] test(platform-api): drop a stale sentinel reference from a test comment --- .../internal/handler/api_upstream_integration_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/platform-api/internal/handler/api_upstream_integration_test.go b/platform-api/internal/handler/api_upstream_integration_test.go index 2cfdabed1e..66c53af5d7 100644 --- a/platform-api/internal/handler/api_upstream_integration_test.go +++ b/platform-api/internal/handler/api_upstream_integration_test.go @@ -118,9 +118,9 @@ func restAPIBody(opRef string) string { }`, restAPIProject, opRef) } -// TestAPIHandler_CreateInvalidUpstreamRefReturns400 pins the handler's mapping of -// ErrInvalidUpstreamDefinition to HTTP 400 on create: an unresolved per-operation ref must -// surface as a validation failure, not an internal error. +// 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() From 5dbd5d0f2e529f346eb4974a29b0b31ab05e8646 Mon Sep 17 00:00:00 2001 From: mehara-rothila Date: Fri, 17 Jul 2026 10:54:42 +0530 Subject: [PATCH 5/5] test(platform-api): apply review polish to comments and test setup Checks the PRAGMA foreign_keys errors in both new test setups, corrects the service test env comment that overstated production wiring, condenses the UpstreamDefinitionYAML doc comment to sibling density, and widens the validateUpstreamRefs call-site comment to cover the pool and union checks it performs. --- platform-api/internal/dto/api.go | 4 +--- .../internal/handler/api_upstream_integration_test.go | 4 +++- platform-api/internal/service/api.go | 2 +- .../internal/service/upstream_validation_test.go | 9 ++++++--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/platform-api/internal/dto/api.go b/platform-api/internal/dto/api.go index 1bee2c35a4..fd7a7455c9 100644 --- a/platform-api/internal/dto/api.go +++ b/platform-api/internal/dto/api.go @@ -161,9 +161,7 @@ type UpstreamTarget struct { Ref string `yaml:"ref,omitempty"` } -// UpstreamDefinitionYAML represents a reusable named upstream definition in the -// deployment YAML (the API-level upstreamDefinitions array the gateway resolves -// refs against). +// UpstreamDefinitionYAML represents a reusable named upstream definition in the deployment YAML type UpstreamDefinitionYAML struct { Name string `yaml:"name"` BasePath string `yaml:"basePath,omitempty"` diff --git a/platform-api/internal/handler/api_upstream_integration_test.go b/platform-api/internal/handler/api_upstream_integration_test.go index 66c53af5d7..cec34d8aa0 100644 --- a/platform-api/internal/handler/api_upstream_integration_test.go +++ b/platform-api/internal/handler/api_upstream_integration_test.go @@ -54,7 +54,9 @@ func setupAPIHandlerEnv(t *testing.T) (http.Handler, func()) { if err != nil { t.Fatalf("open sqlite: %v", err) } - sqlDB.Exec("PRAGMA foreign_keys = ON") + 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")) diff --git a/platform-api/internal/service/api.go b/platform-api/internal/service/api.go index b955aa41d0..df8185d436 100644 --- a/platform-api/internal/service/api.go +++ b/platform-api/internal/service/api.go @@ -746,7 +746,7 @@ func (s *APIService) validateCreateAPIRequest(req *api.CreateRESTAPIRequest, org return err } - // Validate that every upstream ref (API-level + per-operation) resolves to a declared upstreamDefinition + // 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()) } diff --git a/platform-api/internal/service/upstream_validation_test.go b/platform-api/internal/service/upstream_validation_test.go index 3de51901d5..9e5f2429d2 100644 --- a/platform-api/internal/service/upstream_validation_test.go +++ b/platform-api/internal/service/upstream_validation_test.go @@ -330,8 +330,9 @@ const upstreamITOrgUUID = "org-upstream-it" const upstreamITProjectUUID = "proj-upstream-it" const upstreamITProjectHandle = "upstream-it-proj" -// setupUpstreamITEnv creates a real SQLite-backed APIService (wired as server.go -// wires it in production) plus a seeded organization and project. +// 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() @@ -341,7 +342,9 @@ func setupUpstreamITEnv(t *testing.T) *APIService { if err != nil { t.Fatalf("open db: %v", err) } - sqlDB.Exec("PRAGMA foreign_keys = ON") + 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() })