Repository: avenga/couper
Pull request #630 (2022-11-21 15:03:18)

Allow evaluation for the `defaults` block
Allow env variables and functions;

File merge is done on hclsyntax level to prevent early value evals without configured eval context.

---
<details>
    <summary>Reviewer checklist</summary>
    <ul>
        <li>Read PR description: a summary about the changes is required</li>
        <li>Changelog updated</li>
        <li>Documentation: docs/{Reference, Cli, ...}, Docker and cli help/usage</li>
        <li>Pulled branch, manually tested</li>
        <li>Verified requirements are met</li>
        <li>Reviewed the code</li>
        <li>Reviewed the related tests</li>
    </ul>
</details>



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1694cdd9..a8fd0d5f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ Unreleased changes are available as `avenga/couper:edge` container.
 * **Changed**
   * Replaced the JWT library because the former library was no longer maintained ([#612](https://github.com/avenga/couper/pull/612))
   * Routing and [OpenAPI validation](https://docs.couper.io/configuration/block/openapi) now use gorilla/mux ([#614](https://github.com/avenga/couper/pull/614))
+  * Usage of `env` variables and functions is now possible for the `defaults` block ([#630](https://github.com/avenga/couper/pull/630))
 
 * **Fixed**
   * Aligned the evaluation of [`beta_oauth2`](https://docs.couper.io/configuration/block/oauth2_ac)/[`oidc`](https://docs.couper.io/configuration/block/oidc) `redirect_uri` to `saml` `sp_acs_url` ([#589](https://github.com/avenga/couper/pull/589))
diff --git a/config/configload/helper.go b/config/configload/helper.go
index 2dfe7182..7adf2fd1 100644
--- a/config/configload/helper.go
+++ b/config/configload/helper.go
@@ -12,7 +12,6 @@ import (
 	hclbody "github.com/avenga/couper/config/body"
 	"github.com/avenga/couper/config/sequence"
 	"github.com/avenga/couper/errors"
-	"github.com/avenga/couper/eval"
 )
 
 type helper struct {
@@ -23,16 +22,11 @@ type helper struct {
 }
 
 // newHelper creates a container with some methods to keep things simple here and there.
-func newHelper(body hcl.Body, src [][]byte, environment string) (*helper, error) {
-	defaultsBlock := &config.DefaultsBlock{}
-	if diags := gohcl.DecodeBody(body, nil, defaultsBlock); diags.HasErrors() {
-		return nil, diags
-	}
-
+func newHelper(body hcl.Body) (*helper, error) {
 	couperConfig := &config.Couper{
-		Context:     eval.NewContext(src, defaultsBlock.Defaults, environment),
+		Context:     evalContext,
 		Definitions: &config.Definitions{},
-		Defaults:    defaultsBlock.Defaults,
+		Defaults:    defaultsConfig,
 		Settings:    config.NewDefaultSettings(),
 	}
 
@@ -45,7 +39,7 @@ func newHelper(body hcl.Body, src [][]byte, environment string) (*helper, error)
 	return &helper{
 		config:       couperConfig,
 		content:      content,
-		context:      couperConfig.Context.(*eval.Context).HCLContext(),
+		context:      evalContext.HCLContext(),
 		defsBackends: make(map[string]*hclsyntax.Body),
 	}, nil
 }
diff --git a/config/configload/load.go b/config/configload/load.go
index c4b78156..22ac7481 100644
--- a/config/configload/load.go
+++ b/config/configload/load.go
@@ -75,14 +75,16 @@ func init() {
 
 func updateContext(body hcl.Body, srcBytes [][]byte, environment string) hcl.Diagnostics {
 	defaultsBlock := &config.DefaultsBlock{}
-	if diags := gohcl.DecodeBody(body, nil, defaultsBlock); diags.HasErrors() {
+	// defaultsCtx is a temporary one to allow env variables and functions for defaults {}
+	defaultsCtx := eval.NewContext(srcBytes, nil, environment).HCLContext()
+	if diags := gohcl.DecodeBody(body, defaultsCtx, defaultsBlock); diags.HasErrors() {
 		return diags
 	}
+	defaultsConfig = defaultsBlock.Defaults // global assign
 
 	// We need the "envContext" to be able to resolve absolute paths in the config.
-	defaultsConfig = defaultsBlock.Defaults
 	evalContext = eval.NewContext(srcBytes, defaultsConfig, environment)
-	envContext = evalContext.HCLContext()
+	envContext = evalContext.HCLContext() // global assign
 
 	return nil
 }
@@ -179,7 +181,7 @@ func bodiesToConfig(parsedBodies []*hclsyntax.Body, srcBytes [][]byte, env strin
 		return nil, err
 	}
 
-	conf, err := LoadConfig(configBody, srcBytes, env)
+	conf, err := LoadConfig(configBody)
 	if err != nil {
 		return nil, err
 	}
@@ -206,12 +208,12 @@ func LoadFiles(filesList []string, env string) (*config.Couper, error) {
 
 	if env == "" {
 		settingsBlock := mergeSettings(parsedBodies)
-		settings := &config.Settings{}
-		if diags := gohcl.DecodeBody(settingsBlock.Body, nil, settings); diags.HasErrors() {
+		confSettings := &config.Settings{}
+		if diags := gohcl.DecodeBody(settingsBlock.Body, nil, confSettings); diags.HasErrors() {
 			return nil, diags
 		}
-		if settings.Environment != "" {
-			return LoadFiles(filesList, settings.Environment)
+		if confSettings.Environment != "" {
+			return LoadFiles(filesList, confSettings.Environment)
 		}
 	}
 
@@ -257,6 +259,10 @@ func loadTestContents(tcs []testContent) (*config.Couper, error) {
 }
 
 func LoadBytes(src []byte, filename string) (*config.Couper, error) {
+	return LoadBytesEnv(src, filename, "")
+}
+
+func LoadBytesEnv(src []byte, filename, env string) (*config.Couper, error) {
 	hclBody, err := parser.Load(src, filename)
 	if err != nil {
 		return nil, err
@@ -266,17 +272,17 @@ func LoadBytes(src []byte, filename string) (*config.Couper, error) {
 		return nil, err
 	}
 
-	return LoadConfig(hclBody, [][]byte{src}, "")
+	return bodiesToConfig([]*hclsyntax.Body{hclBody}, [][]byte{src}, env)
 }
 
-func LoadConfig(body *hclsyntax.Body, src [][]byte, environment string) (*config.Couper, error) {
+func LoadConfig(body *hclsyntax.Body) (*config.Couper, error) {
 	var err error
 
 	if diags := ValidateConfigSchema(body, &config.Couper{}); diags.HasErrors() {
 		return nil, diags
 	}
 
-	helper, err := newHelper(body, src, environment)
+	helper, err := newHelper(body)
 	if err != nil {
 		return nil, err
 	}
diff --git a/config/configload/merge.go b/config/configload/merge.go
index 311632b1..27d1b405 100644
--- a/config/configload/merge.go
+++ b/config/configload/merge.go
@@ -563,48 +563,82 @@ func mergeDefinitions(bodies []*hclsyntax.Body) (*hclsyntax.Block, map[string]*h
 
 func mergeDefaults(bodies []*hclsyntax.Body) (*hclsyntax.Block, error) {
 	attrs := make(hclsyntax.Attributes)
-	envVars := make(map[string]cty.Value)
+	envVars := make(map[string]hclsyntax.Expression)
 
 	for _, body := range bodies {
 		for _, block := range body.Blocks {
-			if block.Type == defaults {
-				for name, attr := range block.Body.Attributes {
-					if name == environmentVars {
-						v, err := eval.Value(nil, attr.Expr)
-						if err != nil {
-							return nil, err
-						}
+			if block.Type != defaults {
+				continue
+			}
 
-						for k, value := range v.AsValueMap() {
-							if value.Type() != cty.String {
-								return nil, fmt.Errorf("value in 'environment_variables' is not a string")
-							}
+			for name, attr := range block.Body.Attributes {
+				if name != environmentVars {
+					attrs[name] = attr // Currently not used
+					continue
+				}
+
+				expObj, ok := attr.Expr.(*hclsyntax.ObjectConsExpr)
+				if !ok {
+					r := attr.Expr.Range()
+					return nil, newDiagErr(&r, fmt.Sprintf("%s must be object type", environmentVars))
+				}
 
-							envVars[k] = value
+				for _, item := range expObj.Items {
+					k := item.KeyExpr.(*hclsyntax.ObjectConsKeyExpr)
+					r := item.KeyExpr.Range()
+					var keyName string
+					switch exp := k.Wrapped.(type) {
+					case *hclsyntax.ScopeTraversalExpr:
+						if len(exp.Traversal) > 1 {
+							return nil, newDiagErr(&r, "unsupported key scope traversal expression")
 						}
-					} else {
-						attrs[name] = attr // Currently not used
+						keyName = exp.Traversal.RootName()
+					case *hclsyntax.TemplateExpr:
+						if !exp.IsStringLiteral() {
+							return nil, newDiagErr(&r, "unsupported key template expression")
+						}
+						v, _ := exp.Value(nil)
+						keyName = v.AsString()
+					default:
+						r := item.KeyExpr.Range()
+						return nil, newDiagErr(&r, "unsupported key expression")
 					}
+
+					envVars[keyName] = item.ValueExpr
 				}
 			}
 		}
 	}
 
 	if len(envVars) > 0 {
+		items := make([]hclsyntax.ObjectConsItem, 0)
+		for k, v := range envVars {
+			items = append(items, hclsyntax.ObjectConsItem{
+				KeyExpr: &hclsyntax.ObjectConsKeyExpr{
+					Wrapped: &hclsyntax.ScopeTraversalExpr{
+						Traversal: hcl.Traversal{hcl.TraverseRoot{Name: k}},
+					},
+					ForceNonLiteral: false,
+				},
+				ValueExpr: v,
+			})
+		}
+
 		attrs[environmentVars] = &hclsyntax.Attribute{
 			Name: environmentVars,
-			Expr: &hclsyntax.LiteralValueExpr{
-				Val: cty.MapVal(envVars),
+			Expr: &hclsyntax.ObjectConsExpr{
+				Items: items,
 			},
 		}
 	}
 
-	return &hclsyntax.Block{
+	defaultsBlock := &hclsyntax.Block{
 		Type: defaults,
 		Body: &hclsyntax.Body{
 			Attributes: attrs,
 		},
-	}, nil
+	}
+	return defaultsBlock, nil
 }
 
 func mergeSettings(bodies []*hclsyntax.Body) *hclsyntax.Block {
diff --git a/config/defaults.go b/config/defaults.go
index 904b543f..164f6ad1 100644
--- a/config/defaults.go
+++ b/config/defaults.go
@@ -5,7 +5,7 @@ import "github.com/hashicorp/hcl/v2"
 type DefaultEnvVars map[string]string
 
 type Defaults struct {
-	EnvironmentVariables DefaultEnvVars `hcl:"environment_variables,optional" docs:"One or more environment variable assignments"`
+	EnvironmentVariables DefaultEnvVars `hcl:"environment_variables,optional" docs:"One or more environment variable assignments. Keys must be either identifiers or simple string expressions."`
 }
 
 type DefaultsBlock struct {
diff --git a/docs/website/content/2.configuration/4.block/defaults.md b/docs/website/content/2.configuration/4.block/defaults.md
index 9a8f4fe8..05738ffb 100644
--- a/docs/website/content/2.configuration/4.block/defaults.md
+++ b/docs/website/content/2.configuration/4.block/defaults.md
@@ -12,7 +12,7 @@ The `defaults` block lets you define default values.
 values: [
   {
     "default": "",
-    "description": "One or more environment variable assignments",
+    "description": "One or more environment variable assignments. Keys must be either identifiers or simple string expressions.",
     "name": "environment_variables",
     "type": "object"
   }


Test patch:
diff --git a/config/configload/load_test.go b/config/configload/load_test.go
index 1c92e221..cbcbfcab 100644
--- a/config/configload/load_test.go
+++ b/config/configload/load_test.go
@@ -616,6 +616,44 @@ func TestConfigErrors(t *testing.T) {
 			}`,
 			`configuration error: referenced backend "as" is not defined`,
 		},
+		{
+			"wrong environment_variables type",
+			`server {}
+			defaults {
+			  environment_variables = "val"
+			}`,
+			"couper.hcl:3,30-35: environment_variables must be object type; ",
+		},
+		{
+			"unsupported key scope traversal expression",
+			`server {}
+			defaults {
+			  environment_variables = {
+			    env.FOO = "val"
+			  }
+			}`,
+			"couper.hcl:4,8-15: unsupported key scope traversal expression; ",
+		},
+		{
+			"unsupported key template expression",
+			`server {}
+			defaults {
+			  environment_variables = {
+			    "key${1 + 0}" = "val"
+			  }
+			}`,
+			"couper.hcl:4,8-21: unsupported key template expression; ",
+		},
+		{
+			"unsupported key expression",
+			`server {}
+			defaults {
+			  environment_variables = {
+			    to_upper("key") = "val"
+			  }
+			}`,
+			"couper.hcl:4,8-23: unsupported key expression; ",
+		},
 	}
 
 	for _, tt := range tests {
diff --git a/eval/context_test.go b/eval/context_test.go
index db38cd1a..2e4c1011 100644
--- a/eval/context_test.go
+++ b/eval/context_test.go
@@ -13,7 +13,6 @@ import (
 	"github.com/zclconf/go-cty/cty"
 
 	"github.com/avenga/couper/config/configload"
-	"github.com/avenga/couper/config/parser"
 	"github.com/avenga/couper/config/request"
 	"github.com/avenga/couper/eval"
 	"github.com/avenga/couper/internal/seetie"
@@ -191,7 +190,8 @@ func TestDefaultEnvVariables(t *testing.T) {
 		t.Run(tt.name, func(subT *testing.T) {
 			cf, err := configload.LoadBytes([]byte(tt.hcl), "couper.hcl")
 			if err != nil {
-				subT.Fatal(err)
+				subT.Error(err)
+				return
 			}
 
 			hclContext := cf.Context.(*eval.Context).HCLContext()
@@ -236,14 +236,7 @@ func TestCouperVariables(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
-			bytes := []byte(tt.hcl)
-			hclBody, err := parser.Load(bytes, "couper.hcl")
-			if err != nil {
-				subT.Error(err)
-				return
-			}
-
-			cf, err := configload.LoadConfig(hclBody, [][]byte{bytes}, tt.env)
+			cf, err := configload.LoadBytesEnv([]byte(tt.hcl), "couper.hcl", tt.env)
 			if err != nil {
 				subT.Error(err)
 				return
diff --git a/server/http_integration_test.go b/server/http_integration_test.go
index 7fc3854b..b6af7db5 100644
--- a/server/http_integration_test.go
+++ b/server/http_integration_test.go
@@ -518,6 +518,41 @@ func TestHTTPServer_EnvVars(t *testing.T) {
 	}
 }
 
+func TestHTTPServer_DefaultEnvVars(t *testing.T) {
+	helper := test.New(t)
+	client := newClient()
+
+	env.SetTestOsEnviron(func() []string {
+		return []string{"VALUE_4=value4"}
+	})
+	defer env.SetTestOsEnviron(os.Environ)
+
+	shutdown, hook := newCouper("testdata/integration/env/02_couper.hcl", test.New(t))
+	defer shutdown()
+
+	hook.Reset()
+
+	req, err := http.NewRequest(http.MethodGet, "http://example.com:8080", nil)
+	helper.Must(err)
+
+	res, err := client.Do(req)
+	helper.Must(err)
+
+	if res.StatusCode != http.StatusOK {
+		t.Errorf("expected 200, got %d", res.StatusCode)
+	}
+
+	b, err := io.ReadAll(res.Body)
+	helper.Must(err)
+
+	var result []string
+	helper.Must(json.Unmarshal(b, &result))
+
+	if diff := cmp.Diff(result, []string{"value1", "", "default_value_3", "value4", "value5"}); diff != "" {
+		t.Error(diff)
+	}
+}
+
 func TestHTTPServer_XFHHeader(t *testing.T) {
 	client := newClient()
 
diff --git a/server/http_test.go b/server/http_test.go
index 4399ee8f..f5650865 100644
--- a/server/http_test.go
+++ b/server/http_test.go
@@ -176,8 +176,7 @@ func TestHTTPServer_ServeHTTP_Files2(t *testing.T) {
 	helper.Must(err)
 
 	error404Content := []byte("<html><body><h1>route not found error: My custom error template</h1></body></html>")
-	spaContent, err := os.ReadFile(conf.Servers[0].SPAs[0].BootstrapFile)
-	helper.Must(err)
+	spaContent := []byte("<html><body><h1>vue.js</h1></body></html>")
 
 	tmpStoreCh := make(chan struct{})
 	defer close(tmpStoreCh)
diff --git a/server/testdata/integration/env/02_couper.hcl b/server/testdata/integration/env/02_couper.hcl
new file mode 100644
index 00000000..93ff7348
--- /dev/null
+++ b/server/testdata/integration/env/02_couper.hcl
@@ -0,0 +1,17 @@
+server {
+  endpoint "/" {
+    response {
+      json_body = [env.KEY1, env.KEY2, env.KEY3, env.KEY4, env.KEY5]
+    }
+  }
+}
+
+defaults {
+  environment_variables = {
+    KEY1 = "value1"
+    KEY2 = env.VALUE_2
+    KEY3 = default(env.VALUE_3, "default_value_3")
+    KEY4 = env.VALUE_4
+    "KEY5" = "value5"
+  }
+}