Skip to content

Commit 4cfd850

Browse files
feat(gateway): per-operation upstream reuses the upstream-definition cluster
1 parent c95569e commit 4cfd850

24 files changed

Lines changed: 3482 additions & 441 deletions

gateway/gateway-controller/api/management-openapi.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4132,6 +4132,41 @@ components:
41324132
description: List of policies applied only to this operation (overrides or adds to API-level policies)
41334133
items:
41344134
$ref: "#/components/schemas/Policy"
4135+
upstream:
4136+
$ref: "#/components/schemas/RestAPIOperationUpstream"
4137+
description: Per-operation upstream override with main and sandbox sub-fields.
4138+
4139+
RestAPIOperationUpstream:
4140+
type: object
4141+
additionalProperties: false
4142+
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.
4143+
anyOf:
4144+
- required: [main]
4145+
- required: [sandbox]
4146+
properties:
4147+
main:
4148+
description: Production vhost override. Must reference a named upstreamDefinition.
4149+
allOf:
4150+
- $ref: "#/components/schemas/RestAPIOperationUpstreamTarget"
4151+
sandbox:
4152+
description: Sandbox vhost override. Must reference a named upstreamDefinition.
4153+
allOf:
4154+
- $ref: "#/components/schemas/RestAPIOperationUpstreamTarget"
4155+
4156+
RestAPIOperationUpstreamTarget:
4157+
type: object
4158+
additionalProperties: false
4159+
required:
4160+
- ref
4161+
description: A ref-only upstream pointer for operation-level overrides. URLs are not permitted at the operation level; all backend URLs must be declared in spec.upstreamDefinitions and referenced by name.
4162+
properties:
4163+
ref:
4164+
type: string
4165+
description: Name of a predefined upstream declared in spec.upstreamDefinitions.
4166+
minLength: 1
4167+
maxLength: 100
4168+
pattern: '^[a-zA-Z0-9\-_]+$'
4169+
example: my-upstream-1
41354170

41364171
Policy:
41374172
type: object

gateway/gateway-controller/cmd/controller/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,11 @@ func main() {
390390
llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver)
391391
transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer)
392392
policyManager.SetTransformers(transformerRegistry)
393+
// The policy manager receives the transformer registry; the snapshot
394+
// manager's xDS translator does not. The main xDS flow therefore uses
395+
// translateAPIConfig directly, while RuntimeDeployConfig output is
396+
// consumed only by the policy xDS flow. Both flows derive the same
397+
// cluster names.
393398

394399
// Load runtime configs from existing API configurations on startup.
395400
// We write directly to runtimeStore to avoid triggering N separate snapshot updates;

gateway/gateway-controller/pkg/api/management/generated.go

Lines changed: 394 additions & 255 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gateway/gateway-controller/pkg/config/api_validator.go

Lines changed: 108 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"time"
2727

2828
api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management"
29+
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils/upstreamref"
2930
)
3031

3132
// APIValidator validates API configurations using rule-based validation
@@ -36,6 +37,8 @@ type APIValidator struct {
3637
versionRegex *regexp.Regexp
3738
// urlFriendlyNameRegex matches URL-safe characters for API names
3839
urlFriendlyNameRegex *regexp.Regexp
40+
// upstreamRefRegex enforces the schema pattern for per-op upstream refs
41+
upstreamRefRegex *regexp.Regexp
3942
// policyValidator validates policy references and parameters
4043
policyValidator *PolicyValidator
4144
}
@@ -46,6 +49,7 @@ func NewAPIValidator() *APIValidator {
4649
pathParamRegex: regexp.MustCompile(`\{[a-zA-Z0-9_]+\}`),
4750
versionRegex: regexp.MustCompile(`^v?\d+(\.\d+)?(\.\d+)?$`),
4851
urlFriendlyNameRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_\. ]+$`),
52+
upstreamRefRegex: regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`),
4953
}
5054
}
5155

@@ -244,16 +248,10 @@ func (v *APIValidator) validateUpstreamRef(label string, ref *string, upstreamDe
244248
return errors
245249
}
246250

247-
// Check if the referenced definition exists
248-
found := false
249-
for _, def := range *upstreamDefinitions {
250-
if def.Name == refName {
251-
found = true
252-
break
253-
}
254-
}
255-
256-
if !found {
251+
// Check if the referenced definition exists. Use the shared upstreamref helper
252+
// for the membership lookup so API-level ref validation stays aligned with the
253+
// per-op validator and the translators (one source of truth for ref lookup).
254+
if _, err := upstreamref.FindByName(refName, upstreamDefinitions); err != nil {
257255
errors = append(errors, ValidationError{
258256
Field: "spec.upstream." + label + ".ref",
259257
Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", refName),
@@ -294,6 +292,23 @@ func (v *APIValidator) validateUpstreamDefinitions(definitions *[]api.UpstreamDe
294292
}
295293
namesSeen[def.Name] = true
296294

295+
// Enforce the same name contract the schema declares and that operation-level
296+
// refs are validated against (^[a-zA-Z0-9\-_]+$, max 100 chars), so any valid
297+
// definition name stays referenceable from a per-op upstream override.
298+
if len(def.Name) > 100 {
299+
errors = append(errors, ValidationError{
300+
Field: fmt.Sprintf("spec.upstreamDefinitions[%d].name", i),
301+
Message: "Upstream definition name must not exceed 100 characters",
302+
})
303+
continue
304+
} else if !v.upstreamRefRegex.MatchString(def.Name) {
305+
errors = append(errors, ValidationError{
306+
Field: fmt.Sprintf("spec.upstreamDefinitions[%d].name", i),
307+
Message: "Upstream definition name must match pattern ^[a-zA-Z0-9\\-_]+$",
308+
})
309+
continue
310+
}
311+
297312
// Validate upstreams array
298313
if len(def.Upstreams) == 0 {
299314
errors = append(errors, ValidationError{
@@ -356,15 +371,23 @@ func (v *APIValidator) validateUpstreamDefinitions(definitions *[]api.UpstreamDe
356371

357372
// Timeout validation is limited to connect timeout; request and idle
358373
// timeouts are no longer supported at the upstream definition level.
374+
// Parsed inline rather than via upstreamref.ParseConnectTimeout so the two
375+
// distinct, tested messages below (invalid-format vs non-positive) are kept;
376+
// the shared helper collapses both into a single message.
359377
if def.Timeout != nil && def.Timeout.Connect != nil {
360378
timeoutStr := strings.TrimSpace(*def.Timeout.Connect)
361379
if timeoutStr != "" {
362-
_, err := time.ParseDuration(timeoutStr)
380+
d, err := time.ParseDuration(timeoutStr)
363381
if err != nil {
364382
errors = append(errors, ValidationError{
365383
Field: fmt.Sprintf("spec.upstreamDefinitions[%d].timeout.connect", i),
366384
Message: fmt.Sprintf("Invalid timeout format: %v (expected format: '30s', '1m', '500ms')", err),
367385
})
386+
} else if d <= 0 {
387+
errors = append(errors, ValidationError{
388+
Field: fmt.Sprintf("spec.upstreamDefinitions[%d].timeout.connect", i),
389+
Message: "Connect timeout must be a positive duration",
390+
})
368391
}
369392
}
370393
}
@@ -421,7 +444,7 @@ func (v *APIValidator) validateRestData(spec *api.APIConfigData) []ValidationErr
421444
}
422445

423446
// Validate operations
424-
errors = append(errors, v.validateOperations(spec.Operations)...)
447+
errors = append(errors, v.validateOperations(spec.Operations, spec.UpstreamDefinitions)...)
425448

426449
return errors
427450
}
@@ -552,7 +575,7 @@ func (v *APIValidator) validatePathParametersForAsyncAPIs(path string) bool {
552575
}
553576

554577
// validateOperations validates the operations configuration
555-
func (v *APIValidator) validateOperations(operations []api.Operation) []ValidationError {
578+
func (v *APIValidator) validateOperations(operations []api.Operation, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError {
556579
var errors []ValidationError
557580

558581
if len(operations) == 0 {
@@ -605,11 +628,83 @@ func (v *APIValidator) validateOperations(operations []api.Operation) []Validati
605628
Message: "Operation path has unbalanced braces in parameters",
606629
})
607630
}
631+
632+
// Validate per-operation upstream override (main / sandbox)
633+
if op.Upstream != nil {
634+
errors = append(errors, v.validateOperationUpstream(i, op.Upstream, upstreamDefinitions)...)
635+
}
608636
}
609637

610638
return errors
611639
}
612640

641+
// validateOperationUpstream validates per-operation upstream main and sandbox
642+
// sub-fields. Operation-level upstreams are ref-only — direct URLs are not
643+
// permitted. Each present sub-field must reference a named entry in
644+
// spec.upstreamDefinitions. Error field paths are built as
645+
// spec.operations[N].upstream.<subfield>.ref.
646+
func (v *APIValidator) validateOperationUpstream(opIdx int, up *api.RestAPIOperationUpstream, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError {
647+
var errors []ValidationError
648+
if up == nil {
649+
return errors
650+
}
651+
if up.Main == nil && up.Sandbox == nil {
652+
errors = append(errors, ValidationError{
653+
Field: fmt.Sprintf("spec.operations[%d].upstream", opIdx),
654+
Message: "At least one of 'main' or 'sandbox' must be set",
655+
})
656+
return errors
657+
}
658+
if up.Main != nil {
659+
errs := v.validateOperationUpstreamTarget(opIdx, "main", up.Main, upstreamDefinitions)
660+
errors = append(errors, errs...)
661+
}
662+
if up.Sandbox != nil {
663+
errs := v.validateOperationUpstreamTarget(opIdx, "sandbox", up.Sandbox, upstreamDefinitions)
664+
errors = append(errors, errs...)
665+
}
666+
return errors
667+
}
668+
669+
// validateOperationUpstreamTarget validates a single ref-only operation-level
670+
// upstream target. The ref must resolve to a named entry in upstreamDefinitions.
671+
func (v *APIValidator) validateOperationUpstreamTarget(opIdx int, sub string, target *api.RestAPIOperationUpstreamTarget, upstreamDefinitions *[]api.UpstreamDefinition) []ValidationError {
672+
field := fmt.Sprintf("spec.operations[%d].upstream.%s.ref", opIdx, sub)
673+
674+
refName := strings.TrimSpace(target.Ref)
675+
if refName == "" {
676+
return []ValidationError{{
677+
Field: field,
678+
Message: "Upstream ref is required",
679+
}}
680+
}
681+
682+
if len(refName) > 100 {
683+
return []ValidationError{{
684+
Field: field,
685+
Message: "Upstream ref must not exceed 100 characters",
686+
}}
687+
}
688+
689+
if !v.upstreamRefRegex.MatchString(refName) {
690+
return []ValidationError{{
691+
Field: field,
692+
Message: "Upstream ref must match pattern ^[a-zA-Z0-9\\-_]+$",
693+
}}
694+
}
695+
696+
// Resolve through the shared upstreamref helper so the validator stays aligned
697+
// with the xDS translator and RDC transformer (one source of truth for ref lookup).
698+
if _, err := upstreamref.FindByName(refName, upstreamDefinitions); err != nil {
699+
return []ValidationError{{
700+
Field: field,
701+
Message: fmt.Sprintf("Referenced upstream definition '%s' not found in upstreamDefinitions", refName),
702+
}}
703+
}
704+
705+
return nil
706+
}
707+
613708
// validatePathParameters checks if path parameters have balanced braces
614709
func (v *APIValidator) validatePathParameters(path string) bool {
615710
openCount := strings.Count(path, "{")

0 commit comments

Comments
 (0)