Skip to content
43 changes: 35 additions & 8 deletions gateway/gateway-controller/api/management-openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3016,6 +3016,14 @@ components:
default: deployed
example: deployed

UpstreamReference:
type: string
description: Name of a predefined upstreamDefinition.
minLength: 1
maxLength: 100
pattern: '^[a-zA-Z0-9\-_]+$'
example: my-upstream-1

UpstreamDefinition:
type: object
required:
Expand All @@ -3024,12 +3032,7 @@ components:
description: Reusable upstream configuration with optional timeout and load balancing settings
properties:
name:
type: string
description: Unique identifier for this upstream definition
minLength: 1
maxLength: 100
pattern: '^[a-zA-Z0-9\-_]+$'
example: my-upstream-1
$ref: "#/components/schemas/UpstreamReference"
basePath:
type: string
description: Base path prefix for all endpoints in this upstream (e.g., /api/v2). All requests to this upstream will have this path prepended. Must start with '/' and must not end with '/'; omit for root.
Expand Down Expand Up @@ -3100,8 +3103,7 @@ components:
description: Direct backend URL to route traffic to
example: http://prod-backend:5000/api/v2
ref:
type: string
description: Reference to a predefined upstreamDefinition
$ref: "#/components/schemas/UpstreamReference"
hostRewrite:
type: string
enum:
Expand Down Expand Up @@ -3140,6 +3142,8 @@ components:
$ref: "#/components/schemas/Policy"
resilience:
$ref: "#/components/schemas/Resilience"
upstream:
$ref: "#/components/schemas/OperationUpstream"

OperationMethod:
type: string
Expand Down Expand Up @@ -3209,6 +3213,29 @@ components:
enum: [Exact, RegularExpression]
default: Exact

OperationUpstream:
type: object
additionalProperties: false
description: Per-operation upstream override. Each sub-field must reference a named entry in spec.upstreamDefinitions. Missing sub-fields fall back to API-level upstream. At least one of main or sandbox must be set.
minProperties: 1
properties:
main:
type: object
additionalProperties: false
required:
- ref
properties:
ref:
$ref: "#/components/schemas/UpstreamReference"
sandbox:
type: object
additionalProperties: false
required:
- ref
properties:
ref:
$ref: "#/components/schemas/UpstreamReference"

Policy:
type: object
required:
Expand Down
316 changes: 168 additions & 148 deletions gateway/gateway-controller/pkg/api/management/generated.go

Large diffs are not rendered by default.

112 changes: 99 additions & 13 deletions gateway/gateway-controller/pkg/config/api_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (

api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/upstreamref"
)

// APIValidator validates API configurations using rule-based validation
Expand All @@ -37,6 +38,9 @@ type APIValidator struct {
versionRegex *regexp.Regexp
// urlFriendlyNameRegex matches URL-safe characters for API names
urlFriendlyNameRegex *regexp.Regexp
// upstreamRefRegex enforces the schema pattern for API-level and per-op
// upstream refs
upstreamRefRegex *regexp.Regexp
// policyValidator validates policy references and parameters
policyValidator *PolicyValidator
}
Expand All @@ -47,6 +51,7 @@ func NewAPIValidator() *APIValidator {
pathParamRegex: regexp.MustCompile(`\{[a-zA-Z0-9_]+\}`),
versionRegex: regexp.MustCompile(`^v?\d+(\.\d+)?(\.\d+)?$`),
urlFriendlyNameRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_\. ]+$`),
upstreamRefRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`),
}
}

Expand Down Expand Up @@ -186,6 +191,36 @@ func (v *APIValidator) validateUpstreamUrl(label string, upUrl *string) []Valida
return errors
}

// validateUpstreamRefName enforces the shared UpstreamReference name contract
// (max 100 characters, ^[a-zA-Z0-9\-_]+$) on definition names and refs. The
// message names the field from the trailing segment of its path.
func (v *APIValidator) validateUpstreamRefName(field, value string) []ValidationError {
name := fieldName(field)
if len(value) > 100 {
return []ValidationError{{
Field: field,
Message: fmt.Sprintf("%s must not exceed %d characters", name, 100),
}}
}
if !v.upstreamRefRegex.MatchString(value) {
return []ValidationError{{
Field: field,
Message: name + " must match pattern " + v.upstreamRefRegex.String(),
}}
}
return nil
}

// fieldName returns the trailing path segment of a validation field path
// (for example "spec.operations[2].upstream.main.ref" yields "ref") so error
// messages can name the field without a caller-supplied label.
func fieldName(field string) string {
if i := strings.LastIndex(field, "."); i >= 0 {
return field[i+1:]
}
return field
}

func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError {
var errors []ValidationError

Expand All @@ -200,7 +235,10 @@ func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDe

refName := strings.TrimSpace(*ref)

// Check if upstream definitions are provided
if errs := v.validateUpstreamRefName("spec.upstream."+label+".ref", refName); errs != nil {
return errs
}

if upstreamDefinitions == nil || len(*upstreamDefinitions) == 0 {
errors = append(errors, ValidationError{
Field: "spec.upstream." + label + ".ref",
Expand All @@ -209,16 +247,8 @@ func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDe
return errors
}

// Check if the referenced definition exists
found := false
for _, def := range *upstreamDefinitions {
if def.Name == refName {
found = true
break
}
}

if !found {
// Resolve via the shared upstreamref helper so API-level, per-op, and translator lookups match.
if _, err := upstreamref.FindByName(refName, upstreamDefinitions); err != nil {
errors = append(errors, ValidationError{
Field: "spec.upstream." + label + ".ref",
Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", refName),
Expand Down Expand Up @@ -467,7 +497,7 @@ func (v *APIValidator) validateRestData(spec *api.APIConfigData) []ValidationErr
errors = append(errors, v.validateResilience("spec.resilience", spec.Resilience)...)

// Validate operations
errors = append(errors, v.validateOperations(spec.Operations)...)
errors = append(errors, v.validateOperations(spec.Operations, spec.UpstreamDefinitions)...)

return errors
}
Expand Down Expand Up @@ -560,7 +590,7 @@ func (v *APIValidator) ValidateContext(context string) []ValidationError {
}

// validateOperations validates the operations configuration
func (v *APIValidator) validateOperations(operations []api.Operation) []ValidationError {
func (v *APIValidator) validateOperations(operations []api.Operation, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError {
var errors []ValidationError

if len(operations) == 0 {
Expand Down Expand Up @@ -628,11 +658,67 @@ func (v *APIValidator) validateOperations(operations []api.Operation) []Validati

// Validate operation-level resilience block
errors = append(errors, v.validateResilience(fmt.Sprintf("spec.operations[%d].resilience", i), op.Resilience)...)

// Validate per-operation upstream override (main / sandbox)
errors = append(errors, v.validateOperationUpstream(i, op.Upstream, upstreamDefinitions)...)
}

return errors
}

// validateOperationUpstream validates the ref-only per-operation main/sandbox
// overrides; each present ref must name an entry in upstreamDefinitions.
func (v *APIValidator) validateOperationUpstream(opIdx int, up *api.OperationUpstream, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError {
var errors []ValidationError
if up == nil {
return errors
}
if up.Main == nil && up.Sandbox == nil {
errors = append(errors, ValidationError{
Field: fmt.Sprintf("spec.operations[%d].upstream", opIdx),
Message: "At least one of 'main' or 'sandbox' must be set",
})
return errors
}
if up.Main != nil {
errs := v.validateOperationUpstreamRef(opIdx, "main", up.Main.Ref, upstreamDefinitions)
errors = append(errors, errs...)
}
if up.Sandbox != nil {
errs := v.validateOperationUpstreamRef(opIdx, "sandbox", up.Sandbox.Ref, upstreamDefinitions)
errors = append(errors, errs...)
}
return errors
}

// validateOperationUpstreamRef validates a single operation-level upstream ref.
// The ref must resolve to a named entry in upstreamDefinitions.
func (v *APIValidator) validateOperationUpstreamRef(opIdx int, env, ref string, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError {
field := fmt.Sprintf("spec.operations[%d].upstream.%s.ref", opIdx, env)

refName := strings.TrimSpace(ref)
if refName == "" {
return []ValidationError{{
Field: field,
Message: "Upstream ref is required",
}}
}

if errs := v.validateUpstreamRefName(field, refName); errs != nil {
return errs
}

// Resolve via the shared upstreamref helper (same lookup as the translators).
if _, err := upstreamref.FindByName(refName, upstreamDefinitions); err != nil {
return []ValidationError{{
Field: field,
Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", refName),
}}
}

return nil
}

// validatePathParameters checks if path parameters have balanced braces
func (v *APIValidator) validatePathParameters(path string) bool {
openCount := strings.Count(path, "{")
Expand Down
Loading
Loading