diff --git a/docs-website/router/proxy-capabilities/subgraph-request-header-operations.mdx b/docs-website/router/proxy-capabilities/subgraph-request-header-operations.mdx index 005e33b389..5beac75ae0 100644 --- a/docs-website/router/proxy-capabilities/subgraph-request-header-operations.mdx +++ b/docs-website/router/proxy-capabilities/subgraph-request-header-operations.mdx @@ -53,6 +53,14 @@ headers: - op: "propagate" named: "Subgraph-Secret" default: "some-secret" + + groups: # Apply rules to a cohort of subgraphs (list, regex, or both) + - id: "products-cohort" + subgraphs: [products] + matching: "^products-feature-.+$" + request: + - op: "propagate" + named: "X-Products-Auth" ``` ### What does the snippet do? @@ -61,6 +69,71 @@ With `all` we address all subgraph requests. Next, we can define several rules o The `subgraphs` section allows to propagate headers for specific subgraphs. The name must match with the subgraph name in the Studio. +The `groups` section applies a bundle of rules to every subgraph that matches the group's selector. A group can list subgraphs explicitly (`subgraphs:`), match by regex (`matching:`), or both. This is useful for applying the same rules to a base subgraph and its feature variants without duplicating the same block under every entry. + +### Rule evaluation order + +For each subgraph the router builds a header set in this order: + +1. `headers.all` rules. +2. `headers.groups` entries whose selector matches the subgraph (in config order). +3. `headers.subgraphs.` rules for the exact subgraph name. + +Exact-name rules apply last, so a `headers.subgraphs.products` rule can still override a broader group rule when both target the same header. + +### Subgraph header groups + +Each entry under `groups` accepts: + +* `id` — A unique identifier for the group. Required. Used in router error messages. +* `subgraphs` — Explicit list of subgraph names this group applies to. Names are case-sensitive identifiers. +* `matching` — A Go regular expression evaluated against the subgraph name. +* `negate_match` — If `true`, the regex result is inverted. Subgraphs in the explicit `subgraphs` list are still included positively; `negate_match` only affects the regex. +* `request` — Request rules to apply when the group matches. +* `response` — Response rules to apply when the group matches. + +At least one of `subgraphs` or `matching` must be specified, and at least one of `request` or `response` must be specified. Invalid configurations fail router startup. + +A group with both `subgraphs` and `matching` matches a subgraph if it appears in either (the union — list-match OR regex-match). The group's rules apply at most once per subgraph regardless of how many parts of the selector match. + +### Conflict resolution + +When more than one rule targets the same header name for the same subgraph, the router applies rules in a strict deterministic order and the **last writer wins**. There is no warning, no error, and no validator that detects overlapping rules. + +The full ordering across layers: + +1. `headers.all` rules (in declared order). +2. `headers.groups` entries whose selector matches the subgraph, in **config order**. Within each group, rules apply in declared order. +3. `headers.subgraphs.` rules (in declared order). + +Concretely, **within the groups layer, group order in the YAML is the tiebreaker** — entries are merged first-to-last, and a later group's rule overrides an earlier group's rule for the same header name. The `headers.subgraphs.` layer always runs last and overrides every group rule for that exact subgraph. + +There is one subtle asymmetry between operations: an `op: propagate` rule with no client-supplied value and no `default:` is a no-op and does **not** clear an earlier value. An `op: set` rule, and an `op: propagate` rule with a `default:` set, always write a value. + +```yaml +# Example: two groups targeting X-Foo on the same subgraph +headers: + groups: + - id: first + subgraphs: [products] + request: + - op: set + name: X-Foo + value: from-first + - id: second # second group runs after first + subgraphs: [products] + request: + - op: set + name: X-Foo + value: from-second # → wins; X-Foo == "from-second" + subgraphs: + products: + request: + - op: set + name: X-Foo + value: from-exact # → wins overall; exact runs last +``` + ### Supported header rules Currently, we support the following header rules: diff --git a/docs-website/router/proxy-capabilities/subgraph-response-header-operations.mdx b/docs-website/router/proxy-capabilities/subgraph-response-header-operations.mdx index c2ad93ebfe..a8915df99a 100644 --- a/docs-website/router/proxy-capabilities/subgraph-response-header-operations.mdx +++ b/docs-website/router/proxy-capabilities/subgraph-response-header-operations.mdx @@ -64,6 +64,15 @@ headers: named: "X-User-Id" default: "456" # Set the value when the header was not set algorithm: "last_write" + + groups: # Apply rules to a cohort of subgraphs (list, regex, or both) + - id: "products-cohort" + subgraphs: [products] + matching: "^products-feature-.+$" + response: + - op: "propagate" + named: "X-Products-Trace" + algorithm: "last_write" ``` ### What does the snippet do? @@ -72,6 +81,8 @@ With `all` we address all subgraph requests. Next, we can define several rules o The `subgraphs` section allows to propagate headers for specific subgraphs. The name must match with the subgraph name in the Studio. +The `groups` section applies a bundle of rules to every subgraph that matches the group's selector. A group can list subgraphs explicitly (`subgraphs:`), match by regex (`matching:`), or both. Groups apply after `all` and before exact `subgraphs` rules, so an exact subgraph rule can still override a group rule. See [Subgraph Request Headers Operations](/router/proxy-capabilities/subgraph-request-header-operations#subgraph-header-groups) for the full set of group fields and validation rules. + ### Supported header rules Currently, we support the following header rules: diff --git a/router/core/header_rule_engine.go b/router/core/header_rule_engine.go index bcc6f75438..3293163422 100644 --- a/router/core/header_rule_engine.go +++ b/router/core/header_rule_engine.go @@ -155,6 +155,21 @@ type HeaderPropagation struct { // Precomputed request rule presence for fast-path checks hasAllRequestRules bool subgraphHasRequestRules map[string]bool + // groupRegex holds the compiled regex for each group's `matching` selector. + // The slice is index-aligned with rules.Groups; entries for groups that have + // no `matching` selector are nil. Compiled once at startup. + groupRegex []*regexp.Regexp + // subgraphToGroupIdx maps an explicit `subgraphs:` entry to the indices of the + // groups that list it. Lets us look up group membership in O(1) at request + // time instead of scanning every group's list. + subgraphToGroupIdx map[string][]int + // hasAnyGroupRequestRules is true when at least one group defines request + // rules. Used as a fast-path so subgraphs with no `all`/exact rules can still + // skip group evaluation entirely when no groups are configured. + hasAnyGroupRequestRules bool + // hasAnyGroupRegex is true when at least one group has a `matching` selector, + // so we know whether the group matching loop needs to evaluate regexes at all. + hasAnyGroupRegex bool } func initHeaderRules(rules *config.HeaderRules) { @@ -179,6 +194,14 @@ func NewHeaderPropagation(rules *config.HeaderRules) (*HeaderPropagation, error) compiledRouterResponseRules: map[string]*vm.Program{}, } + // Validate groups, compile their selector regexes, and build the + // subgraph-name -> group-index inverse index. Doing this up-front means + // per-request group evaluation is an O(1) map lookup plus an O(g) regex + // scan over only those groups that actually use `matching`. + if err := hf.indexGroups(); err != nil { + return nil, err + } + rhrs, rhrrs, rrs := hf.getAllRules() hf.hasRequestRules = len(rhrs) > 0 hf.hasResponseRules = len(rhrrs) > 0 @@ -206,6 +229,69 @@ func NewHeaderPropagation(rules *config.HeaderRules) (*HeaderPropagation, error) return &hf, nil } +// indexGroups validates each subgraph header group and populates the lookup +// structures used during request handling. Validation rules: +// - `id` is required and must be unique across groups +// - at least one of `subgraphs` / `matching` must be set (a group with no +// selector would never apply, which is almost always a misconfiguration) +// - at least one of `request` / `response` must be set (an empty group has +// no effect) +// - `matching`, when present, must compile as a Go regular expression +// +// Validation runs at router init so misconfiguration fails startup rather than +// silently producing wrong header sets at request time. +func (h *HeaderPropagation) indexGroups() error { + if len(h.rules.Groups) == 0 { + return nil + } + + h.groupRegex = make([]*regexp.Regexp, len(h.rules.Groups)) + h.subgraphToGroupIdx = make(map[string][]int) + seenID := make(map[string]struct{}, len(h.rules.Groups)) + + for i, g := range h.rules.Groups { + if g == nil { + return fmt.Errorf("headers.groups[%d] is nil", i) + } + if g.ID == "" { + return fmt.Errorf("headers.groups[%d] is missing required field 'id'", i) + } + if _, dup := seenID[g.ID]; dup { + return fmt.Errorf("duplicate headers.groups id %q", g.ID) + } + seenID[g.ID] = struct{}{} + + if len(g.Subgraphs) == 0 && g.Matching == "" { + return fmt.Errorf("headers.groups[%q] must specify at least one of 'subgraphs' or 'matching'", g.ID) + } + if len(g.Request) == 0 && len(g.Response) == 0 { + return fmt.Errorf("headers.groups[%q] must specify at least one of 'request' or 'response' rules", g.ID) + } + + if g.Matching != "" { + re, err := regexp.Compile(g.Matching) + if err != nil { + return fmt.Errorf("invalid regex %q in headers.groups[%q]: %w", g.Matching, g.ID, err) + } + h.groupRegex[i] = re + h.hasAnyGroupRegex = true + } + + for _, name := range g.Subgraphs { + if name == "" { + return fmt.Errorf("headers.groups[%q] contains an empty subgraph name", g.ID) + } + h.subgraphToGroupIdx[name] = append(h.subgraphToGroupIdx[name], i) + } + + if len(g.Request) > 0 { + h.hasAnyGroupRequestRules = true + } + } + + return nil +} + func AddCacheControlPolicyToRules(rules *config.HeaderRules, cacheControl config.CacheControlPolicy) *config.HeaderRules { if rules == nil { rules = &config.HeaderRules{} @@ -246,11 +332,23 @@ func (h *HeaderPropagation) getAllRules() ([]*config.RequestHeaderRule, []*confi for _, subgraph := range h.rules.Subgraphs { rhrs = append(rhrs, subgraph.Request...) } + for _, group := range h.rules.Groups { + if group == nil { + continue + } + rhrs = append(rhrs, group.Request...) + } rhrrs := h.rules.All.Response for _, subgraph := range h.rules.Subgraphs { rhrrs = append(rhrrs, subgraph.Response...) } + for _, group := range h.rules.Groups { + if group == nil { + continue + } + rhrrs = append(rhrrs, group.Response...) + } return rhrs, rhrrs, h.rules.Router.Response } @@ -355,6 +453,18 @@ func (h *HeaderPropagation) BuildRequestHeaderForSubgraph(subgraphName string, c h.applyRequestRuleToHeader(ctx, outHeader, rule) } + // Apply group rules. Groups are evaluated in config order, applying any group + // whose selector (explicit list or regex) matches the subgraph. Groups run + // after `all` and before exact-name rules so an explicit per-subgraph rule + // can still override a group rule (e.g. via op: set). + if subgraphName != "" { + h.forEachMatchingGroup(subgraphName, func(g *config.SubgraphHeaderGroup) { + for _, rule := range g.Request { + h.applyRequestRuleToHeader(ctx, outHeader, rule) + } + }) + } + // Apply subgraph-specific rules if subgraphName != "" { if subRules, ok := h.rules.Subgraphs[subgraphName]; ok { @@ -368,8 +478,68 @@ func (h *HeaderPropagation) BuildRequestHeaderForSubgraph(subgraphName string, c return outHeader, headerHash } +// forEachMatchingGroup invokes fn for every group whose selector matches the +// given subgraph name, in config order. A group matches if either its explicit +// `Subgraphs` list contains the name OR its `Matching` regex matches the name +// (the regex result is inverted when NegateMatch is true). The two halves of +// the selector are OR-combined; explicit list membership is always positive +// regardless of NegateMatch. +// +// The function deduplicates groups: a group that lists the subgraph and also +// matches via its regex still fires only once. +func (h *HeaderPropagation) forEachMatchingGroup(subgraphName string, fn func(*config.SubgraphHeaderGroup)) { + if h == nil || len(h.rules.Groups) == 0 { + return + } + + // Track already-applied group indices to avoid double-applying when both + // list and regex match. Most configs will have small group counts, so a + // fixed-size bitset on the stack via a slice of bools is fine here. + applied := make([]bool, len(h.rules.Groups)) + + // First, apply groups that include the subgraph in their explicit list. The + // inverse index lets us do this in O(1) per match instead of scanning every + // group. + if idxs, ok := h.subgraphToGroupIdx[subgraphName]; ok { + for _, i := range idxs { + if applied[i] { + continue + } + applied[i] = true + } + } + + // Then walk groups in config order and apply: list-matched groups (already + // flagged above) and regex-matched groups (evaluated lazily here). This walk + // preserves config order so users can reason about precedence between + // multiple matching groups. + for i, g := range h.rules.Groups { + if g == nil { + continue + } + if !applied[i] { + // Try regex match if the group has one configured. + if h.hasAnyGroupRegex && h.groupRegex[i] != nil { + matched := h.groupRegex[i].MatchString(subgraphName) + if g.NegateMatch { + matched = !matched + } + if !matched { + continue + } + applied[i] = true + } else { + continue + } + } + fn(g) + } +} + // hasRequestRulesForSubgraph returns true if there are request header rules -// that would apply to the given subgraph. The result is computed at creation time. +// that would apply to the given subgraph. The result is computed at creation +// time for `all` and exact-name rules, and computed on demand for groups +// because group selectors depend on the runtime subgraph name. func (h *HeaderPropagation) hasRequestRulesForSubgraph(subgraphName string) bool { if h == nil || h.rules == nil { return false @@ -382,7 +552,35 @@ func (h *HeaderPropagation) hasRequestRulesForSubgraph(subgraphName string) bool // No subgraph specified and no global rules return false } - return h.subgraphHasRequestRules != nil && h.subgraphHasRequestRules[subgraphName] + if h.subgraphHasRequestRules != nil && h.subgraphHasRequestRules[subgraphName] { + return true + } + if h.hasAnyGroupRequestRules { + // Walk only as far as needed to find a match. The forEachMatchingGroup + // helper is overkill here because we only need a yes/no answer. + if idxs, ok := h.subgraphToGroupIdx[subgraphName]; ok { + for _, i := range idxs { + if g := h.rules.Groups[i]; g != nil && len(g.Request) > 0 { + return true + } + } + } + if h.hasAnyGroupRegex { + for i, g := range h.rules.Groups { + if g == nil || len(g.Request) == 0 || h.groupRegex[i] == nil { + continue + } + matched := h.groupRegex[i].MatchString(subgraphName) + if g.NegateMatch { + matched = !matched + } + if matched { + return true + } + } + } + } + return false } // hashHeaderStable computes a deterministic 64-bit hash over the provided header map. @@ -438,6 +636,17 @@ func (h *HeaderPropagation) ApplyResponseHeaderRules(ctx context.Context, header h.applyResponseRule(propagation, resp, rule) } + // Apply group response rules in config order, after `all` and before exact + // subgraph rules so an explicit per-subgraph rule can still override a + // group rule. + if subgraphName != "" { + h.forEachMatchingGroup(subgraphName, func(g *config.SubgraphHeaderGroup) { + for _, rule := range g.Response { + h.applyResponseRule(propagation, resp, rule) + } + }) + } + if subgraphName != "" { if subgraphRules, ok := h.rules.Subgraphs[subgraphName]; ok { for _, rule := range subgraphRules.Response { @@ -866,7 +1075,14 @@ func createMostRestrictivePolicy(policies []*cachedirective.Object) (*cachedirec return &result, cacheControlHeader } -// SubgraphRules returns the list of header rules for the subgraph with the given name +// SubgraphRules returns the list of header rules for the subgraph with the given name. +// Rules are returned in evaluation order: `all` first, then any matching groups (in +// config order, list-or-regex match), then exact-name rules. Groups whose `matching` +// regex fails to compile here are skipped silently — startup-time validation in +// NewHeaderPropagation is the source of truth for failing the router on bad +// configuration. The engine's pre-origin layer uses this helper to compute the static +// set of "potentially propagated" header names; missing group rules here would +// cause the engine to drop those names from its single-flight key. func SubgraphRules(rules *config.HeaderRules, subgraphName string) []*config.RequestHeaderRule { if rules == nil { return nil @@ -875,6 +1091,45 @@ func SubgraphRules(rules *config.HeaderRules, subgraphName string) []*config.Req if rules.All != nil { subgraphRules = append(subgraphRules, rules.All.Request...) } + if subgraphName != "" && len(rules.Groups) > 0 { + applied := make([]bool, len(rules.Groups)) + // First mark groups whose explicit `subgraphs` list contains the name. + for i, g := range rules.Groups { + if g == nil { + continue + } + for _, s := range g.Subgraphs { + if s == subgraphName { + applied[i] = true + break + } + } + } + // Then walk in config order, filling in regex matches and emitting rules. + for i, g := range rules.Groups { + if g == nil || len(g.Request) == 0 { + continue + } + if !applied[i] { + if g.Matching == "" { + continue + } + re, err := regexp.Compile(g.Matching) + if err != nil { + continue + } + matched := re.MatchString(subgraphName) + if g.NegateMatch { + matched = !matched + } + if !matched { + continue + } + applied[i] = true + } + subgraphRules = append(subgraphRules, g.Request...) + } + } if rules.Subgraphs != nil { if subgraphSpecificRules, ok := rules.Subgraphs[subgraphName]; ok { subgraphRules = append(subgraphRules, subgraphSpecificRules.Request...) diff --git a/router/core/header_rule_engine_buildheader_test.go b/router/core/header_rule_engine_buildheader_test.go index c652deeed4..428d4fc4cf 100644 --- a/router/core/header_rule_engine_buildheader_test.go +++ b/router/core/header_rule_engine_buildheader_test.go @@ -443,3 +443,731 @@ func TestSubgraphHeadersBuilder_ConcurrentAccessSameSubgraph(t *testing.T) { } } } + +// newGroupTestRequestContext returns a minimal *requestContext suitable for driving +// BuildRequestHeaderForSubgraph in unit tests. The provided client headers are +// copied onto the underlying request so propagation rules have something to +// match against. +func newGroupTestRequestContext(t *testing.T, clientHeaders http.Header) *requestContext { + t.Helper() + clientReq := httptest.NewRequest("POST", "http://localhost", nil) + for k, vs := range clientHeaders { + for _, v := range vs { + clientReq.Header.Add(k, v) + } + } + return &requestContext{ + logger: zap.NewNop(), + responseWriter: httptest.NewRecorder(), + request: clientReq, + operation: &operationContext{}, + subgraphResolver: NewSubgraphResolver(nil), + } +} + +// TestBuildRequestHeaderForSubgraph_GroupsListOnly verifies that a group with +// only an explicit `subgraphs` list applies its rules to listed subgraphs and +// is skipped for everything else. +func TestBuildRequestHeaderForSubgraph_GroupsListOnly(t *testing.T) { + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "list-cohort", + Subgraphs: []string{"products", "orders"}, + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-Cohort"}, + }, + }, + }, + }) + require.NoError(t, err) + + ctx := newGroupTestRequestContext(t, http.Header{"X-Cohort": []string{"v"}}) + + for _, sg := range []string{"products", "orders"} { + hdr, _ := ht.BuildRequestHeaderForSubgraph(sg, ctx) + require.NotNilf(t, hdr, "expected header set for listed subgraph %q", sg) + assert.Equalf(t, "v", hdr.Get("X-Cohort"), "expected X-Cohort propagated for %q", sg) + } + + // Subgraph not in the list should not get the rule and, since no other rules + // apply, should fall through to the no-rules fast-path. + other, hash := ht.BuildRequestHeaderForSubgraph("inventory", ctx) + assert.Nil(t, other, "non-listed subgraph should not receive group rules") + assert.Equal(t, uint64(0), hash) +} + +// TestBuildRequestHeaderForSubgraph_GroupsRegexOnly verifies that a group with +// only a `matching` regex (no explicit list) applies based on regex match. +func TestBuildRequestHeaderForSubgraph_GroupsRegexOnly(t *testing.T) { + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "feature-previews", + Matching: "^.+-feature-.+$", + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-Preview"}, + }, + }, + }, + }) + require.NoError(t, err) + + ctx := newGroupTestRequestContext(t, http.Header{"X-Preview": []string{"yes"}}) + + matched, _ := ht.BuildRequestHeaderForSubgraph("products-feature-pr-7", ctx) + require.NotNil(t, matched) + assert.Equal(t, "yes", matched.Get("X-Preview")) + + unmatched, hash := ht.BuildRequestHeaderForSubgraph("products", ctx) + assert.Nil(t, unmatched) + assert.Equal(t, uint64(0), hash) +} + +// TestBuildRequestHeaderForSubgraph_GroupsHybrid verifies that a group with +// both `subgraphs` and `matching` matches a subgraph via either path. The +// hybrid form is the typical "base subgraph + its feature variants" rule. +// Critically, the group must fire only ONCE for a subgraph, even if both the +// list and the regex would match. +func TestBuildRequestHeaderForSubgraph_GroupsHybrid(t *testing.T) { + t.Run("matches via list only", func(t *testing.T) { + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "products-cohort", + Subgraphs: []string{"products"}, + Matching: "^products-feature-.+$", + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-Products"}, + }, + }, + }, + }) + require.NoError(t, err) + ctx := newGroupTestRequestContext(t, http.Header{"X-Products": []string{"v"}}) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", ctx) + require.NotNil(t, hdr) + assert.Equal(t, "v", hdr.Get("X-Products")) + // The base name doesn't match the regex pattern, so this matches via + // the list path only. + }) + + t.Run("matches via regex only", func(t *testing.T) { + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "products-cohort", + Subgraphs: []string{"products"}, + Matching: "^products-feature-.+$", + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-Products"}, + }, + }, + }, + }) + require.NoError(t, err) + ctx := newGroupTestRequestContext(t, http.Header{"X-Products": []string{"v"}}) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products-feature-pr-9", ctx) + require.NotNil(t, hdr) + assert.Equal(t, "v", hdr.Get("X-Products")) + }) + + t.Run("does not match unrelated subgraphs", func(t *testing.T) { + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "products-cohort", + Subgraphs: []string{"products"}, + Matching: "^products-feature-.+$", + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-Products"}, + }, + }, + }, + }) + require.NoError(t, err) + ctx := newGroupTestRequestContext(t, http.Header{"X-Products": []string{"v"}}) + hdr, hash := ht.BuildRequestHeaderForSubgraph("inventory", ctx) + assert.Nil(t, hdr) + assert.Equal(t, uint64(0), hash) + }) + + t.Run("hybrid match fires only once when both list and regex would match", func(t *testing.T) { + // Use op:set with a counter-style rule: if the group ran twice we'd + // see the second value win, but a `set` rule writes the same value + // either way. Use a propagate rule with append-style behavior is + // trickier on the request side; instead assert via a `set` rule that + // the resulting header equals exactly one value (no doubling). + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "double-match", + Subgraphs: []string{"products"}, + Matching: "^products$", // overlaps with list intentionally + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Tag", Value: "once"}, + }, + }, + }, + }) + require.NoError(t, err) + ctx := newGroupTestRequestContext(t, nil) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", ctx) + require.NotNil(t, hdr) + // Single value, not duplicated. set semantics give one entry regardless. + assert.Equal(t, []string{"once"}, hdr.Values("X-Tag")) + }) +} + +// TestBuildRequestHeaderForSubgraph_GroupsNegateMatchInvertsRegexOnly verifies +// that negate_match inverts the regex result but does NOT exclude subgraphs in +// the explicit `subgraphs` list. A subgraph in the list is always included. +func TestBuildRequestHeaderForSubgraph_GroupsNegateMatchInvertsRegexOnly(t *testing.T) { + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "external-or-allowlisted", + Subgraphs: []string{"internal-billing"}, // explicit allowlist + Matching: "^internal-.+$", + NegateMatch: true, + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-Public"}, + }, + }, + }, + }) + require.NoError(t, err) + + ctx := newGroupTestRequestContext(t, http.Header{"X-Public": []string{"yes"}}) + + // External subgraph: regex (negated) matches → applied. + external, _ := ht.BuildRequestHeaderForSubgraph("products", ctx) + require.NotNil(t, external) + assert.Equal(t, "yes", external.Get("X-Public")) + + // Listed subgraph: list always includes regardless of negate_match. + listed, _ := ht.BuildRequestHeaderForSubgraph("internal-billing", ctx) + require.NotNil(t, listed) + assert.Equal(t, "yes", listed.Get("X-Public")) + + // Internal subgraph not in the list: regex matches, but negate_match + // inverts → no rule applied. + internal, hash := ht.BuildRequestHeaderForSubgraph("internal-secrets", ctx) + assert.Nil(t, internal) + assert.Equal(t, uint64(0), hash) +} + +// TestBuildRequestHeaderForSubgraph_GroupsOrder asserts the documented +// evaluation order: headers.all -> groups (config order) -> exact subgraph. +// Each layer sets the same header to a different value via `op: set`, and +// the last writer wins. A subgraph that matches multiple groups should see +// each group apply in the configured order. +func TestBuildRequestHeaderForSubgraph_GroupsOrder(t *testing.T) { + ht, err := NewHeaderPropagation(&config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Layer", Value: "all"}, + }, + }, + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "first-group", + Subgraphs: []string{"products", "products-feature-x"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Layer", Value: "group-1"}, + }, + }, + { + ID: "second-group", + Matching: "^products(-feature-.+)?$", + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Layer", Value: "group-2"}, + }, + }, + }, + Subgraphs: map[string]*config.GlobalHeaderRule{ + "products": { + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Layer", Value: "exact"}, + }, + }, + }, + }) + require.NoError(t, err) + + ctx := newGroupTestRequestContext(t, nil) + + // Exact match: all -> group-1 -> group-2 -> exact. Exact wins. + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", ctx) + require.NotNil(t, hdr) + assert.Equal(t, "exact", hdr.Get("X-Layer")) + + // Feature subgraph: matches group-1 (via list) and group-2 (via regex). No + // exact match. group-2 applies after group-1 in config order so it wins. + feat, _ := ht.BuildRequestHeaderForSubgraph("products-feature-x", ctx) + require.NotNil(t, feat) + assert.Equal(t, "group-2", feat.Get("X-Layer")) + + // Unrelated subgraph: only `all` applies. + other, _ := ht.BuildRequestHeaderForSubgraph("inventory", ctx) + require.NotNil(t, other) + assert.Equal(t, "all", other.Get("X-Layer")) +} + +// TestNewHeaderPropagation_GroupValidation covers all startup-time validation +// branches for headers.groups. Every misconfiguration must fail router init. +func TestNewHeaderPropagation_GroupValidation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + groups []*config.SubgraphHeaderGroup + }{ + { + name: "missing id", + groups: []*config.SubgraphHeaderGroup{ + { + Subgraphs: []string{"a"}, + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-A"}, + }, + }, + }, + }, + { + name: "duplicate id", + groups: []*config.SubgraphHeaderGroup{ + { + ID: "dup", + Subgraphs: []string{"a"}, + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-A"}, + }, + }, + { + ID: "dup", + Subgraphs: []string{"b"}, + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-B"}, + }, + }, + }, + }, + { + name: "empty selector (no subgraphs and no matching)", + groups: []*config.SubgraphHeaderGroup{ + { + ID: "empty-selector", + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-A"}, + }, + }, + }, + }, + { + name: "no rules (request and response both empty)", + groups: []*config.SubgraphHeaderGroup{ + { + ID: "no-rules", + Subgraphs: []string{"a"}, + }, + }, + }, + { + name: "invalid regex", + groups: []*config.SubgraphHeaderGroup{ + { + ID: "bad-regex", + Matching: "[invalid", + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-A"}, + }, + }, + }, + }, + { + name: "empty subgraph name in list", + groups: []*config.SubgraphHeaderGroup{ + { + ID: "empty-name", + Subgraphs: []string{""}, + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-A"}, + }, + }, + }, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := NewHeaderPropagation(&config.HeaderRules{Groups: tc.groups}) + require.Error(t, err, "expected validation error for case %q", tc.name) + }) + } +} + +// TestBuildRequestHeaderForSubgraph_NoGroupsFastPath asserts that configs +// without any groups behave identically to the pre-groups baseline. This is +// the "existing users pay nothing" guarantee. +func TestBuildRequestHeaderForSubgraph_NoGroupsFastPath(t *testing.T) { + ht, err := NewHeaderPropagation(&config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-A"}, + }, + }, + Subgraphs: map[string]*config.GlobalHeaderRule{ + "products": { + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-B"}, + }, + }, + }, + }) + require.NoError(t, err) + + ctx := newGroupTestRequestContext(t, http.Header{ + "X-A": []string{"a"}, + "X-B": []string{"b"}, + }) + + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", ctx) + require.NotNil(t, hdr) + assert.Equal(t, "a", hdr.Get("X-A")) + assert.Equal(t, "b", hdr.Get("X-B")) +} + +// TestSubgraphRules_GroupsIncluded confirms the package-level helper used by +// the engine's pre-origin layer (via FetchURLRules) surfaces rules +// contributed by matching groups. Without this, the engine's single-flight +// key would not include group-propagated headers and request deduplication +// could drop them. +func TestSubgraphRules_GroupsIncluded(t *testing.T) { + rules := &config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-All"}, + }, + }, + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "list-group", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-FromList"}, + }, + }, + { + ID: "regex-group", + Matching: "^products(-feature-.+)?$", + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-FromRegex"}, + }, + }, + }, + Subgraphs: map[string]*config.GlobalHeaderRule{ + "products": { + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-Exact"}, + }, + }, + }, + } + + got := SubgraphRules(rules, "products") + require.Len(t, got, 4) + assert.Equal(t, "X-All", got[0].Named) + assert.Equal(t, "X-FromList", got[1].Named) + assert.Equal(t, "X-FromRegex", got[2].Named) + assert.Equal(t, "X-Exact", got[3].Named) + + // Feature subgraph: list miss, regex hit, no exact entry. + gotFeature := SubgraphRules(rules, "products-feature-pr-1") + require.Len(t, gotFeature, 2) + assert.Equal(t, "X-All", gotFeature[0].Named) + assert.Equal(t, "X-FromRegex", gotFeature[1].Named) + + // Unrelated subgraph: no group matches. + gotOther := SubgraphRules(rules, "inventory") + require.Len(t, gotOther, 1) + assert.Equal(t, "X-All", gotOther[0].Named) +} + +// TestBuildRequestHeaderForSubgraph_GroupsPrecedence pins down how conflicts +// between rules from different groups (or between groups and other layers) +// are resolved. There is no warning, no error, and no conflict detection: the +// router applies rules in a strict deterministic order and the last writer +// for a given header name wins. These tests lock that contract in place so +// future refactors don't accidentally change observable behavior. +func TestBuildRequestHeaderForSubgraph_GroupsPrecedence(t *testing.T) { + t.Run("two groups set same header — config order wins (last writer)", func(t *testing.T) { + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "first", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "from-first"}, + }, + }, + { + ID: "second", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "from-second"}, + }, + }, + }, + }) + require.NoError(t, err) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", newGroupTestRequestContext(t, nil)) + require.NotNil(t, hdr) + assert.Equal(t, "from-second", hdr.Get("X-Foo"), + "second group in config order must win when two groups set the same header") + }) + + t.Run("set overwrites earlier propagate value (client sent header)", func(t *testing.T) { + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "propagator", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-Foo"}, + }, + }, + { + ID: "setter", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "from-set"}, + }, + }, + }, + }) + require.NoError(t, err) + ctx := newGroupTestRequestContext(t, http.Header{"X-Foo": []string{"client-value"}}) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", ctx) + require.NotNil(t, hdr) + assert.Equal(t, "from-set", hdr.Get("X-Foo"), + "a later op:set must overwrite an earlier propagated value") + }) + + t.Run("propagate (with client value) overwrites earlier set value", func(t *testing.T) { + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "setter", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "from-set"}, + }, + }, + { + ID: "propagator", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-Foo"}, + }, + }, + }, + }) + require.NoError(t, err) + ctx := newGroupTestRequestContext(t, http.Header{"X-Foo": []string{"client-value"}}) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", ctx) + require.NotNil(t, hdr) + assert.Equal(t, "client-value", hdr.Get("X-Foo"), + "a later op:propagate must overwrite an earlier set value when the client sent the header") + }) + + t.Run("propagate without default is a no-op when client header missing", func(t *testing.T) { + // This is a subtle but important asymmetry between op:set and op:propagate. + // A propagate rule that finds no client value (and has no default) leaves + // the existing value in place rather than clearing it. + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "setter", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "from-set"}, + }, + }, + { + ID: "propagator", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-Foo"}, // no default + }, + }, + }, + }) + require.NoError(t, err) + // Note: no X-Foo on the client request. + ctx := newGroupTestRequestContext(t, nil) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", ctx) + require.NotNil(t, hdr) + assert.Equal(t, "from-set", hdr.Get("X-Foo"), + "op:propagate with no client value and no default must not clobber an earlier set value") + }) + + t.Run("propagate with default overwrites earlier set value when client header missing", func(t *testing.T) { + // A `default:` makes propagate behave like set when the client didn't send + // the header. This rounds out the matrix vs the test above. + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "setter", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "from-set"}, + }, + }, + { + ID: "propagator-with-default", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "propagate", Named: "X-Foo", Default: "from-default"}, + }, + }, + }, + }) + require.NoError(t, err) + ctx := newGroupTestRequestContext(t, nil) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", ctx) + require.NotNil(t, hdr) + assert.Equal(t, "from-default", hdr.Get("X-Foo"), + "op:propagate with a default must overwrite an earlier set value when the client header is missing") + }) + + t.Run("rule order within a single group: last writer wins", func(t *testing.T) { + // Confirms that conflict resolution applies inside a group's rule list + // the same way it does between groups. + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "intra-group", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "first"}, + {Operation: "set", Name: "X-Foo", Value: "second"}, + {Operation: "set", Name: "X-Foo", Value: "third"}, + }, + }, + }, + }) + require.NoError(t, err) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", newGroupTestRequestContext(t, nil)) + require.NotNil(t, hdr) + assert.Equal(t, "third", hdr.Get("X-Foo"), + "the last rule in a group's request list must win for the same header name") + }) + + t.Run("exact subgraph rule overrides every matching group", func(t *testing.T) { + // All three layers target X-Foo. exact must win regardless of how many + // groups matched first. + ht, err := NewHeaderPropagation(&config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "from-all"}, + }, + }, + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "g1", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "from-g1"}, + }, + }, + { + ID: "g2", + Matching: "^products$", + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "from-g2"}, + }, + }, + }, + Subgraphs: map[string]*config.GlobalHeaderRule{ + "products": { + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "from-exact"}, + }, + }, + }, + }) + require.NoError(t, err) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", newGroupTestRequestContext(t, nil)) + require.NotNil(t, hdr) + assert.Equal(t, "from-exact", hdr.Get("X-Foo"), + "exact subgraph rules must override any group rule") + }) + + t.Run("different header names from different groups all coexist", func(t *testing.T) { + // Conflict resolution is per-header-name. Rules touching different + // headers all apply, regardless of group order. + ht, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "g1", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-A", Value: "a"}, + }, + }, + { + ID: "g2", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-B", Value: "b"}, + }, + }, + { + ID: "g3", + Matching: "^products$", + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-C", Value: "c"}, + }, + }, + }, + }) + require.NoError(t, err) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", newGroupTestRequestContext(t, nil)) + require.NotNil(t, hdr) + assert.Equal(t, "a", hdr.Get("X-A")) + assert.Equal(t, "b", hdr.Get("X-B")) + assert.Equal(t, "c", hdr.Get("X-C")) + }) + + t.Run("config order matters: swap groups and the winner changes", func(t *testing.T) { + // Same two groups, opposite config order, opposite winner. This is the + // "yes, group order is the tiebreaker" assertion in pure form. + buildAndAssert := func(t *testing.T, groupOrder []*config.SubgraphHeaderGroup, expected string) { + t.Helper() + ht, err := NewHeaderPropagation(&config.HeaderRules{Groups: groupOrder}) + require.NoError(t, err) + hdr, _ := ht.BuildRequestHeaderForSubgraph("products", newGroupTestRequestContext(t, nil)) + require.NotNil(t, hdr) + assert.Equal(t, expected, hdr.Get("X-Foo")) + } + + alpha := &config.SubgraphHeaderGroup{ + ID: "alpha", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "alpha"}, + }, + } + beta := &config.SubgraphHeaderGroup{ + ID: "beta", + Subgraphs: []string{"products"}, + Request: []*config.RequestHeaderRule{ + {Operation: "set", Name: "X-Foo", Value: "beta"}, + }, + } + + buildAndAssert(t, []*config.SubgraphHeaderGroup{alpha, beta}, "beta") + buildAndAssert(t, []*config.SubgraphHeaderGroup{beta, alpha}, "alpha") + }) +} diff --git a/router/core/header_rule_engine_test.go b/router/core/header_rule_engine_test.go index 6a8f2711f5..9e7666193d 100644 --- a/router/core/header_rule_engine_test.go +++ b/router/core/header_rule_engine_test.go @@ -1,6 +1,7 @@ package core import ( + "context" "net/http" "sync" "testing" @@ -384,3 +385,90 @@ func TestNewHeaderPropagation(t *testing.T) { assert.False(t, hp.HasResponseRules()) }) } + +// TestApplyResponseHeaderRules_Groups verifies that subgraph header groups +// also drive response-side propagation. A subgraph matched by a group's +// selector (list, regex, or hybrid) must have its response headers +// propagated to the client, while an unrelated subgraph is unaffected. +func TestApplyResponseHeaderRules_Groups(t *testing.T) { + t.Parallel() + + hp, err := NewHeaderPropagation(&config.HeaderRules{ + Groups: []*config.SubgraphHeaderGroup{ + { + ID: "list-only", + Subgraphs: []string{"orders"}, + Response: []*config.ResponseHeaderRule{ + { + Operation: config.HeaderRuleOperationPropagate, + Named: "X-Order-Trace", + Algorithm: config.ResponseHeaderRuleAlgorithmLastWrite, + }, + }, + }, + { + ID: "regex-only", + Matching: "^.+-feature-.+$", + Response: []*config.ResponseHeaderRule{ + { + Operation: config.HeaderRuleOperationPropagate, + Named: "X-Feature-Trace", + Algorithm: config.ResponseHeaderRuleAlgorithmLastWrite, + }, + }, + }, + }, + }) + require.NoError(t, err) + + cases := []struct { + name string + subgraph string + respHdr string + respValue string + expectKey string + expectVal string // empty means: header must NOT be set + }{ + { + name: "list match propagates to client", + subgraph: "orders", + respHdr: "X-Order-Trace", + respValue: "abc-123", + expectKey: "X-Order-Trace", + expectVal: "abc-123", + }, + { + name: "regex match propagates to client", + subgraph: "products-feature-pr-7", + respHdr: "X-Feature-Trace", + respValue: "feat-xyz", + expectKey: "X-Feature-Trace", + expectVal: "feat-xyz", + }, + { + name: "non-matching subgraph does not propagate", + subgraph: "inventory", + respHdr: "X-Order-Trace", + respValue: "should-not-propagate", + expectKey: "X-Order-Trace", + expectVal: "", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + prop := &responseHeaderPropagation{ + header: make(http.Header), + m: &sync.Mutex{}, + } + ctx := context.WithValue(context.Background(), responseHeaderPropagationKey{}, prop) + subResp := http.Header{} + subResp.Set(tc.respHdr, tc.respValue) + + hp.ApplyResponseHeaderRules(ctx, subResp, tc.subgraph, 200, nil) + assert.Equal(t, tc.expectVal, prop.header.Get(tc.expectKey)) + }) + } +} diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 8646eae8f6..4c38fd0f81 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -280,10 +280,42 @@ type CacheControlPolicy struct { type HeaderRules struct { // All is a set of rules that apply to all requests - All *GlobalHeaderRule `yaml:"all,omitempty"` - Subgraphs map[string]*GlobalHeaderRule `yaml:"subgraphs,omitempty"` - CookieWhitelist []string `yaml:"cookie_whitelist,omitempty"` - Router RouterHeaderRules `yaml:"router,omitempty"` + All *GlobalHeaderRule `yaml:"all,omitempty"` + // Subgraphs is a map of rules keyed by exact subgraph name. The name must match + // the subgraph name in the Studio. + Subgraphs map[string]*GlobalHeaderRule `yaml:"subgraphs,omitempty"` + // Groups is an ordered list of named header rule bundles. Each group's selector + // can be an explicit list of subgraph names (`subgraphs:`), a Go regex against the + // subgraph name (`matching:`), or both (a subgraph qualifies if it matches either + // the list or the regex). Groups apply after `all` and before exact-name rules, + // so a `headers.subgraphs.` rule can still override a group rule. + Groups []*SubgraphHeaderGroup `yaml:"groups,omitempty"` + CookieWhitelist []string `yaml:"cookie_whitelist,omitempty"` + Router RouterHeaderRules `yaml:"router,omitempty"` +} + +// SubgraphHeaderGroup applies a set of request/response header rules to every +// subgraph that matches the group's selector. The selector is the union of the +// explicit `Subgraphs` list and the `Matching` regex; at least one must be set. +// Selectors are validated and regexes compiled at startup; an invalid group +// fails router initialization. +type SubgraphHeaderGroup struct { + // ID is a required, unique identifier used in error messages and (potentially + // in the future) telemetry labels. It has no effect on matching semantics. + ID string `yaml:"id"` + // Subgraphs is an explicit list of subgraph names this group applies to. + // Names are case-sensitive identifiers. Optional if `Matching` is set. + Subgraphs []string `yaml:"subgraphs,omitempty"` + // Matching is a Go regular expression evaluated against the subgraph name. + // Optional if `Subgraphs` is set. + Matching string `yaml:"matching,omitempty"` + // NegateMatch inverts the result of the `Matching` regex only. Subgraphs in the + // explicit `Subgraphs` list are always included, regardless of NegateMatch. + NegateMatch bool `yaml:"negate_match,omitempty"` + // Request rules to apply to every subgraph the group matches. + Request []*RequestHeaderRule `yaml:"request,omitempty"` + // Response rules to apply to every subgraph the group matches. + Response []*ResponseHeaderRule `yaml:"response,omitempty"` } type RouterHeaderRules struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index d300d76a80..f83b278165 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -1902,6 +1902,65 @@ } } }, + "groups": { + "type": "array", + "description": "An ordered list of named header rule bundles. Each group's selector can be an explicit list of subgraph names ('subgraphs'), a Go regex against the subgraph name ('matching'), or both (the union — a subgraph matches if it appears in either). Groups apply after 'all' and before exact 'subgraphs.' rules, so an exact-name rule can still override a group rule. This is useful for applying the same set of rules to a cohort of subgraphs (including base subgraphs and their feature variants) without duplicating the same block under every entry.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "A unique identifier for the group. Used in router error messages and reserved for future telemetry labels. Has no effect on matching." + }, + "subgraphs": { + "type": "array", + "description": "Explicit list of subgraph names this group applies to. Names are case-sensitive identifiers. Optional if 'matching' is set; at least one of 'subgraphs' or 'matching' must be specified.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "matching": { + "type": "string", + "description": "A Go regular expression evaluated against the subgraph name. Optional if 'subgraphs' is set; at least one of 'subgraphs' or 'matching' must be specified." + }, + "negate_match": { + "type": "boolean", + "description": "If true, the result of the 'matching' regex is inverted. Subgraphs in the explicit 'subgraphs' list are still included positively; negate_match only affects the regex.", + "default": false + }, + "request": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/traffic_shaping_header_rule" + }, + { + "$ref": "#/$defs/set_header_rule" + } + ] + } + }, + "response": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/traffic_shaping_header_response_rule" + }, + { + "$ref": "#/$defs/set_header_rule" + } + ] + } + } + } + } + }, "cookie_whitelist": { "type": "array", "description": "A list of Cookie keys allowed to be forwarded to the subgraph. If the list is empty or unspecified, all cookies are forwarded. This option will do nothing if the 'Cookie' header is not propagated.", diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index d1ff08a786..3b165eb6ae 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -291,6 +291,30 @@ headers: - op: 'set' name: 'X-Subgraph-Key' value: 'some-subgraph-secret' + groups: + - id: list-only-group + subgraphs: + - subgraph-a + - subgraph-b + request: + - op: 'propagate' + named: 'X-Group-List' + - id: regex-only-group + matching: '^.+-feature-.+$' + request: + - op: 'propagate' + named: 'X-Group-Regex' + response: + - op: 'propagate' + algorithm: 'last_write' + named: 'X-Group-Trace' + - id: hybrid-group + subgraphs: + - products + matching: '^products-feature-.+$' + request: + - op: 'propagate' + named: 'X-Products-Auth' cookie_whitelist: - 'cookie1' - 'cookie2' diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 5c7bbc25da..8ead59879e 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -198,6 +198,7 @@ "Headers": { "All": null, "Subgraphs": null, + "Groups": null, "CookieWhitelist": null, "Router": { "Response": null diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index f084bf9f28..cef22b798b 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -370,6 +370,88 @@ ] } }, + "Groups": [ + { + "ID": "list-only-group", + "Subgraphs": [ + "subgraph-a", + "subgraph-b" + ], + "Matching": "", + "NegateMatch": false, + "Request": [ + { + "Operation": "propagate", + "Matching": "", + "NegateMatch": false, + "Named": "X-Group-List", + "Rename": "", + "Default": "", + "Name": "", + "Value": "", + "Expression": "", + "ValueFrom": null + } + ], + "Response": null + }, + { + "ID": "regex-only-group", + "Subgraphs": null, + "Matching": "^.+-feature-.+$", + "NegateMatch": false, + "Request": [ + { + "Operation": "propagate", + "Matching": "", + "NegateMatch": false, + "Named": "X-Group-Regex", + "Rename": "", + "Default": "", + "Name": "", + "Value": "", + "Expression": "", + "ValueFrom": null + } + ], + "Response": [ + { + "Operation": "propagate", + "Matching": "", + "NegateMatch": false, + "Named": "X-Group-Trace", + "Rename": "", + "Default": "", + "Algorithm": "last_write", + "Name": "", + "Value": "" + } + ] + }, + { + "ID": "hybrid-group", + "Subgraphs": [ + "products" + ], + "Matching": "^products-feature-.+$", + "NegateMatch": false, + "Request": [ + { + "Operation": "propagate", + "Matching": "", + "NegateMatch": false, + "Named": "X-Products-Auth", + "Rename": "", + "Default": "", + "Name": "", + "Value": "", + "Expression": "", + "ValueFrom": null + } + ], + "Response": null + } + ], "CookieWhitelist": [ "cookie1", "cookie2"