Skip to content

feat: client response expressions#2472

Merged
SkArchon merged 11 commits intomainfrom
milinda/expression-headers
Feb 3, 2026
Merged

feat: client response expressions#2472
SkArchon merged 11 commits intomainfrom
milinda/expression-headers

Conversation

@SkArchon
Copy link
Copy Markdown
Contributor

@SkArchon SkArchon commented Jan 27, 2026

This PR allows for the use of response client expressions, currently we only support header propagation from the subgraph level. Even for cases like setting a static value like "test" for a header on responses, this would run for every subgraph. The problem with this is that the requestContext's expression context has not been updated with information such as errors, response headers, etc. As this is processed after the subgraph level round trippers. (The use case was that the user wants to access the error attribute in expressions, which is not populated at this level).

In order to mitigate this we introduce client expressions, which runs once before returning the response.

Summary by CodeRabbit

  • New Features

    • Router-level response header rules: set or derive response headers via static values or expressions. Empty results are ignored; evaluation errors are logged without failing requests. Configuration, schema, defaults, and example fixtures updated to expose these rules.
  • Tests

    • Added comprehensive tests covering static/dynamic expressions, multiple headers, precedence with other header rules, empty/invalid cases, error handling, and logging.

Checklist

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 27, 2026

Walkthrough

Adds router-level response header rules: new config/schema types and fixtures, splits compiled header expressions into request vs router-response maps, wires HeaderPropagation into Router and GraphQL handler, and adds unit and integration tests for router response header evaluation, error handling, and logging.

Changes

Cohort / File(s) Summary
Configuration structures & schema
router/pkg/config/config.go, router/pkg/config/config.schema.json, router/pkg/config/fixtures/full.yaml, router/pkg/config/testdata/config_defaults.json, router/pkg/config/testdata/config_full.json
Introduce RouterHeaderRules and RouterResponseHeaderRule; add Router field to HeaderRules and JSON schema definition router_response_header_rule; update fixtures and testdata with router response header entries.
Header rule engine & tests
router/core/header_rule_engine.go, router/core/header_rule_engine_test.go
Split compiled rules into compiledRequestRules and compiledRouterResponseRules; extend getAllRules to return router response rules; add internal processExpression, getRouterResponseRuleExpressionValue, public ApplyRouterResponseHeaderRules, and associated unit tests covering evaluation, empty handling, and error cases.
Router & handler integration
router/core/router.go, router/core/router_config.go, router/core/graph_server.go, router/core/graphql_handler.go
Add headerPropagation *HeaderPropagation to Config and Router; add HeaderPropagation to HandlerOptions and GraphQLHandler; wire headerPropagation into graph mux options and call ApplyRouterResponseHeaderRules during request handling.
Integration tests
Router tests
router-tests/header_propagation_test.go
Add integration tests "Router Response Header Rules" covering static and request-derived expressions, multi-header cases, interactions with subgraph propagation, empty/missing resolution, error paths, and runtime logging assertions (imports go.uber.org/zap/zapcore).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: client response expressions' directly and clearly summarizes the main change—introducing client response expressions for response-level header evaluation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Important

Action Needed: IP Allowlist Update

If your organization protects your Git platform with IP whitelisting, please add the new CodeRabbit IP address to your allowlist:

  • 136.113.208.247/32 (new)
  • 34.170.211.100/32
  • 35.222.179.152/32

Failure to add the new IP will result in interrupted reviews.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jan 27, 2026

Router-nonroot image scan passed

✅ No security vulnerabilities found in image:

ghcr.io/wundergraph/cosmo/router:sha-8f74003507128061d9b6e94936645ec9e316c2ae-nonroot

@SkArchon SkArchon marked this pull request as ready for review January 27, 2026 14:36
@codecov
Copy link
Copy Markdown

codecov Bot commented Jan 27, 2026

Codecov Report

❌ Patch coverage is 81.13208% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.63%. Comparing base (810344a) to head (17e142a).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
router/core/header_rule_engine.go 78.72% 5 Missing and 5 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2472      +/-   ##
==========================================
+ Coverage   61.60%   61.63%   +0.03%     
==========================================
  Files         229      229              
  Lines       23860    23894      +34     
==========================================
+ Hits        14698    14728      +30     
- Misses       7923     7924       +1     
- Partials     1239     1242       +3     
Files with missing lines Coverage Δ
router/core/graph_server.go 82.04% <100.00%> (-0.16%) ⬇️
router/core/graphql_handler.go 70.37% <100.00%> (+0.44%) ⬆️
router/core/router.go 70.46% <100.00%> (+0.02%) ⬆️
router/core/router_config.go 93.67% <ø> (ø)
router/pkg/config/config.go 80.51% <ø> (ø)
router/core/header_rule_engine.go 88.57% <78.72%> (-0.72%) ⬇️

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@router/core/header_rule_engine.go`:
- Around line 248-277: The response rule "expression" field is never compiled or
evaluated; update compileExpressionRules to also compile response rules (call
processExpression for each response rule and store programs in a
compiledResponseRules map on HeaderPropagation using the same expr.Manager), and
update applyResponseRule to evaluate the compiled program for the response rule
(look up hf.compiledResponseRules[rule.Expression] and execute via the expr VM
to decide rule application), or alternatively validate and reject any response
rules that contain an Expression by returning an error from
compileExpressionRules; reference functions: compileExpressionRules,
processExpression, applyResponseRule and the compiledResponseRules map on
HeaderPropagation when implementing the fix.
🧹 Nitpick comments (1)
router/core/router_config.go (1)

99-101: Include client header rules in usage tracking.

Now that client rules exist, Usage() can report header_rules as disabled when only headers.client is configured. Consider counting Client as well.

✅ Suggested update
- usage["header_rules"] = c.headerRules != nil && (c.headerRules.All != nil || len(c.headerRules.Subgraphs) > 0)
+ usage["header_rules"] = c.headerRules != nil &&
+ 	(c.headerRules.All != nil || len(c.headerRules.Subgraphs) > 0 || len(c.headerRules.Client) > 0)

Comment thread router/core/header_rule_engine.go Outdated
Comment thread router/pkg/config/config.go Outdated
Comment thread router/core/header_rule_engine.go Outdated
Comment thread router/core/header_rule_engine_test.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
router/core/header_rule_engine.go (1)

629-642: ⚠️ Potential issue | 🟡 Minor

Inconsistent error formatting with getRequestRuleExpressionValue.

Line 639 explicitly calls err.Error() while the equivalent line 624 in getRequestRuleExpressionValue uses %s with the error directly. Both work correctly, but for consistency, use the same style.

🔧 Suggested fix for consistency
 func (h *HeaderPropagation) getClientRuleExpressionValue(rule *config.ClientHeaderRule, reqCtx *requestContext) (value string, err error) {
 	if reqCtx == nil {
 		return "", fmt.Errorf("context cannot be nil")
 	}
 	program, ok := h.compiledClientRules[rule.Expression]
 	if !ok {
 		return "", fmt.Errorf("expression %s not found in compiled rules for header rule %s", rule.Expression, rule.Name)
 	}
 	value, err = expr.ResolveStringExpression(program, reqCtx.expressionContext)
 	if err != nil {
-		return "", fmt.Errorf("unable to resolve expression %q for header rule %s: %s", rule.Expression, rule.Name, err.Error())
+		return "", fmt.Errorf("unable to resolve expression %q for header rule %s: %s", rule.Expression, rule.Name, err)
 	}
 	return
 }
🧹 Nitpick comments (2)
router/pkg/config/config.go (1)

333-337: Consider adding documentation comments for consistency.

Unlike RequestHeaderRule and ResponseHeaderRule which have detailed comments explaining each field, ClientHeaderRule lacks documentation. Adding comments would improve maintainability.

📝 Suggested documentation
 type ClientHeaderRule struct {
-	// Set header options
-	Name       string `yaml:"name"`
-	Expression string `yaml:"expression"`
+	// Name is the name of the header to set on the client response
+	Name string `yaml:"name"`
+	// Expression is the Expr Lang expression to evaluate for dynamic header values.
+	// Expressions are evaluated once after all subgraph requests complete,
+	// allowing access to errors, response headers, and other post-subgraph information.
+	Expression string `yaml:"expression"`
 }
router/core/header_rule_engine.go (1)

265-278: Consider renaming the hf parameter for clarity.

The parameter name hf in processExpression is misleading since it's a map[string]*vm.Program, not a HeaderPropagation instance. A more descriptive name would improve readability.

♻️ Suggested rename
-func processExpression(expression string, hf map[string]*vm.Program, manager *expr.Manager) error {
+func processExpression(expression string, compiledRules map[string]*vm.Program, manager *expr.Manager) error {
 	if expression == "" {
 		return nil
 	}
-	if _, ok := hf[expression]; ok {
+	if _, ok := compiledRules[expression]; ok {
 		return nil
 	}
 	program, err := manager.CompileExpression(expression, reflect.String)
 	if err != nil {
 		return fmt.Errorf("error compiling expression %s for header rule: %w", expression, err)
 	}
-	hf[expression] = program
+	compiledRules[expression] = program
 	return nil
 }

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@router/core/header_rule_engine.go`:
- Around line 703-719: ApplyClientResponseHeaderRules currently dereferences
h.rules.Client and can panic if the receiver h is nil; add the same nil-receiver
guard used by methods like HasRequestRules/HasResponseRules at the start of
ApplyClientResponseHeaderRules (e.g., if h == nil || h.rules == nil ||
len(h.rules.Client) == 0 { return nil }) so the method safely returns when h or
h.rules is nil and only iterates rules when present; keep the existing error
handling and header setting logic unchanged.
🧹 Nitpick comments (1)
router/core/header_rule_engine.go (1)

265-278: Consider renaming the hf parameter to avoid confusion.

The parameter hf map[string]*vm.Program shadows the common pattern where hf refers to HeaderPropagation elsewhere in this file. Consider renaming to cache or compiledRules for clarity.

♻️ Suggested rename
-func processExpression(expression string, hf map[string]*vm.Program, manager *expr.Manager) error {
+func processExpression(expression string, compiledRules map[string]*vm.Program, manager *expr.Manager) error {
 	if expression == "" {
 		return nil
 	}
-	if _, ok := hf[expression]; ok {
+	if _, ok := compiledRules[expression]; ok {
 		return nil
 	}
 	program, err := manager.CompileExpression(expression, reflect.String)
 	if err != nil {
 		return fmt.Errorf("error compiling expression %s for header rule: %w", expression, err)
 	}
-	hf[expression] = program
+	compiledRules[expression] = program
 	return nil
 }

Comment thread router/core/header_rule_engine.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@router/core/header_rule_engine.go`:
- Around line 703-718: ApplyRouterResponseHeaderRules in HeaderPropagation
currently deletes the header when getRouterResponseRuleExpressionValue returns
an empty string; instead treat empty as "no mutation". Modify the loop over
h.rules.Router.Response (and the handling around rule.Name and value) so that
when value == "" you do nothing (no Header().Del), only call
w.Header().Set(rule.Name, value) when value is non-empty; keep existing continue
for rule.Expression == "" and preserve error handling from
getRouterResponseRuleExpressionValue.

Comment thread router/core/header_rule_engine.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
router/core/header_rule_engine.go (1)

147-216: ⚠️ Potential issue | 🟡 Minor

Include router response rules in hasResponseRules flag.

hasResponseRules only reflects subgraph response rules. If a config has only router response rules and no subgraph rules, the flag will be false, preventing OnOriginResponse from being registered as a post-origin handler even though router rules will be applied separately. Update the flag assignment to include router rules:

Suggested fix
-	hf.hasResponseRules = len(rhrrs) > 0
+	hf.hasResponseRules = len(rhrrs) > 0 || len(rrs) > 0
🧹 Nitpick comments (1)
router/core/header_rule_engine.go (1)

251-280: Rename routerRequestRules to routerResponseRules for clarity.

The function compiles router response rules; a more precise name reduces confusion.

Suggested rename
-func (hf *HeaderPropagation) compileExpressionRules(subgraphRequestRules []*config.RequestHeaderRule, routerRequestRules []*config.RouterResponseHeaderRule) error {
+func (hf *HeaderPropagation) compileExpressionRules(subgraphRequestRules []*config.RequestHeaderRule, routerResponseRules []*config.RouterResponseHeaderRule) error {
 	manager := expr.CreateNewExprManager()
 	for _, rule := range subgraphRequestRules {
 		if err := processExpression(rule.Expression, hf.compiledRequestRules, manager); err != nil {
 			return fmt.Errorf("error compiling header %s: %w", rule.Name, err)
 		}
 	}

-	for _, rule := range routerRequestRules {
+	for _, rule := range routerResponseRules {
 		if err := processExpression(rule.Expression, hf.compiledRouterResponseRules, manager); err != nil {
 			return fmt.Errorf("error compiling header %s: %w", rule.Name, err)
 		}
 	}

@SkArchon SkArchon merged commit f585037 into main Feb 3, 2026
40 of 41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants