Repository: avenga/couper
Pull request #561 (2022-08-22 14:51:45)

Reusable proxies
Labeled `proxy` blocks defined in `definitions` can be referenced with the `proxy` attribute in an `endpoint`:
```hcl
server {
  api {
    endpoint "/a" {
      beta_required_permission = "pr1"
      proxy = "p"
    }
    endpoint "/a" {
      beta_required_permission = "pr2"
      proxy = "p"
    }
    endpoint "/c" {
      beta_required_permission = "pr3"
      proxy = "p"
    }
  }
}
# ...
definitions {
  proxy "p" {
    backend = "b"
    # ...
  }
  backend "b" {
    # ...
  }
}
```

---
<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 63a25ce6..0c4a2946 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ Unreleased changes are available as `avenga/couper:edge` container.
   * used go version in `version` command ([#552](https://github.com/avenga/couper/pull/552))
   * new `grant_type`s `"password"` and `"urn:ietf:params:oauth:grant-type:jwt-bearer"` with related attributes for [`oauth2` block](https://docs.couper.io/configuration/block/oauth2) ([#555](https://github.com/avenga/couper/pull/555))
   * [`beta_token_request` block](https://docs.couper.io/configuration/block/token_request), [`backend`](https://docs.couper.io/configuration/variables#backend) and [`beta_token_response`](https://docs.couper.io/configuration/variables#beta_token_response) variables and `beta_token(s)` properties of [`backends` variable](https://docs.couper.io/configuration/variables#backends) ([#517](https://github.com/avenga/couper/pull/517))
+  * reusable [`proxy` block](https://docs.couper.io/configuration/block/proxy) ([#561](https://github.com/avenga/couper/pull/561))
 
 * **Changed**
  * Starting will now fail if `environment` blocks are used without `COUPER_ENVIRONMENT` being set ([#546](https://github.com/avenga/couper/pull/546))
diff --git a/config/configload/load.go b/config/configload/load.go
index 65452528..f4a51483 100644
--- a/config/configload/load.go
+++ b/config/configload/load.go
@@ -154,12 +154,12 @@ func bodiesToConfig(parsedBodies []*hclsyntax.Body, srcBytes [][]byte, env strin
 
 	settingsBlock := mergeSettings(parsedBodies)
 
-	definitionsBlock, err := mergeDefinitions(parsedBodies)
+	definitionsBlock, proxies, err := mergeDefinitions(parsedBodies)
 	if err != nil {
 		return nil, err
 	}
 
-	serverBlocks, err := mergeServers(parsedBodies)
+	serverBlocks, err := mergeServers(parsedBodies, proxies)
 	if err != nil {
 		return nil, err
 	}
diff --git a/config/configload/merge.go b/config/configload/merge.go
index 84027b74..b31a6783 100644
--- a/config/configload/merge.go
+++ b/config/configload/merge.go
@@ -18,7 +18,7 @@ const (
 	errUniqueLabels     = "All %s blocks must have unique labels."
 )
 
-func mergeServers(bodies []*hclsyntax.Body) (hclsyntax.Blocks, error) {
+func mergeServers(bodies []*hclsyntax.Body, proxies map[string]*hclsyntax.Block) (hclsyntax.Blocks, error) {
 	type (
 		namedBlocks   map[string]*hclsyntax.Block
 		apiDefinition struct {
@@ -151,6 +151,10 @@ func mergeServers(bodies []*hclsyntax.Body) (hclsyntax.Blocks, error) {
 						return nil, newMergeError(errUniqueLabels, block)
 					}
 
+					if err := addProxy(block, proxies); err != nil {
+						return nil, err
+					}
+
 					results[serverKey].endpoints[block.Labels[0]] = block
 				} else if block.Type == api {
 					var apiKey string
@@ -198,6 +202,10 @@ func mergeServers(bodies []*hclsyntax.Body) (hclsyntax.Blocks, error) {
 								return nil, newMergeError(errUniqueLabels, subBlock)
 							}
 
+							if err := addProxy(subBlock, proxies); err != nil {
+								return nil, err
+							}
+
 							results[serverKey].apis[apiKey].endpoints[subBlock.Labels[0]] = subBlock
 						} else if subBlock.Type == errorHandler {
 							if err := absInBackends(subBlock); err != nil {
@@ -401,11 +409,12 @@ func mergeServers(bodies []*hclsyntax.Body) (hclsyntax.Blocks, error) {
 	return mergedServers, nil
 }
 
-func mergeDefinitions(bodies []*hclsyntax.Body) (*hclsyntax.Block, error) {
+func mergeDefinitions(bodies []*hclsyntax.Body) (*hclsyntax.Block, map[string]*hclsyntax.Block, error) {
 	type data map[string]*hclsyntax.Block
 	type list map[string]data
 
 	definitionsBlock := make(list)
+	proxiesList := make(data)
 
 	for _, body := range bodies {
 		for _, outerBlock := range body.Blocks {
@@ -415,8 +424,6 @@ func mergeDefinitions(bodies []*hclsyntax.Body) (*hclsyntax.Block, error) {
 						definitionsBlock[innerBlock.Type] = make(data)
 					}
 
-					definitionsBlock[innerBlock.Type][innerBlock.Labels[0]] = innerBlock
-
 					// Count the "backend" blocks and "backend" attributes to
 					// forbid multiple backend definitions.
 
@@ -425,7 +432,7 @@ func mergeDefinitions(bodies []*hclsyntax.Body) (*hclsyntax.Block, error) {
 					for _, block := range innerBlock.Body.Blocks {
 						if block.Type == errorHandler {
 							if err := absInBackends(block); err != nil {
-								return nil, err
+								return nil, nil, err
 							}
 						} else if block.Type == backend {
 							backends++
@@ -437,7 +444,28 @@ func mergeDefinitions(bodies []*hclsyntax.Body) (*hclsyntax.Block, error) {
 					}
 
 					if backends > 1 {
-						return nil, newMergeError(errMultipleBackends, innerBlock)
+						return nil, nil, newMergeError(errMultipleBackends, innerBlock)
+					}
+
+					if innerBlock.Type != proxy {
+						definitionsBlock[innerBlock.Type][innerBlock.Labels[0]] = innerBlock
+					} else {
+						label := innerBlock.Labels[0]
+
+						if attr, ok := innerBlock.Body.Attributes["name"]; ok {
+							name, err := attrStringValue(attr)
+							if err != nil {
+								return nil, nil, err
+							}
+
+							innerBlock.Labels[0] = name
+
+							delete(innerBlock.Body.Attributes, "name")
+						} else {
+							innerBlock.Labels[0] = defaultNameLabel
+						}
+
+						proxiesList[label] = innerBlock
 					}
 				}
 			}
@@ -457,7 +485,7 @@ func mergeDefinitions(bodies []*hclsyntax.Body) (*hclsyntax.Block, error) {
 		Body: &hclsyntax.Body{
 			Blocks: blocks,
 		},
-	}, nil
+	}, proxiesList, nil
 }
 
 func mergeDefaults(bodies []*hclsyntax.Body) (*hclsyntax.Block, error) {
@@ -588,6 +616,41 @@ func absInBackends(block *hclsyntax.Block) error {
 	return nil
 }
 
+func addProxy(block *hclsyntax.Block, proxies map[string]*hclsyntax.Block) error {
+	if attr, ok := block.Body.Attributes[proxy]; ok {
+		reference, err := attrStringValue(attr)
+		if err != nil {
+			return err
+		}
+
+		if proxyBlock, ok := proxies[reference]; !ok {
+			sr := attr.Expr.StartRange()
+
+			return newDiagErr(&sr, "proxy reference is not defined")
+		} else {
+			delete(block.Body.Attributes, proxy)
+
+			block.Body.Blocks = append(block.Body.Blocks, proxyBlock)
+		}
+	}
+
+	return nil
+}
+
+func attrStringValue(attr *hclsyntax.Attribute) (string, error) {
+	v, err := eval.Value(nil, attr.Expr)
+	if err != nil {
+		return "", err
+	}
+
+	if v.Type() != cty.String {
+		sr := attr.Expr.StartRange()
+		return "", newDiagErr(&sr, fmt.Sprintf("%s must evaluate to string", attr.Name))
+	}
+
+	return v.AsString(), nil
+}
+
 // newErrorHandlerKey returns a merge key based on a possible mixed error-kind format.
 // "label1" and/or "label2 label3" results in "label1 label2 label3".
 func newErrorHandlerKey(block *hclsyntax.Block) (key string) {
diff --git a/config/configload/validate.go b/config/configload/validate.go
index 44dd8870..0ad87f0f 100644
--- a/config/configload/validate.go
+++ b/config/configload/validate.go
@@ -43,6 +43,7 @@ func validateBody(body hcl.Body, afterMerge bool) error {
 			uniqueBackends := make(map[string]struct{})
 			uniqueACs := make(map[string]struct{})
 			uniqueJWTSigningProfiles := make(map[string]struct{})
+			uniqueProxies := make(map[string]struct{})
 			for _, innerBlock := range outerBlock.Body.Blocks {
 				if !afterMerge {
 					if len(innerBlock.Labels) == 0 {
@@ -92,6 +93,13 @@ func validateBody(body hcl.Body, afterMerge bool) error {
 					if err != nil {
 						return err
 					}
+				case proxy:
+					if !afterMerge {
+						if _, set := uniqueProxies[label]; set {
+							return newDiagErr(&labelRange, "proxy labels must be unique")
+						}
+						uniqueProxies[label] = struct{}{}
+					}
 				}
 			}
 		} else if outerBlock.Type == server {
diff --git a/docs/website/content/2.configuration/4.block/definitions.md b/docs/website/content/2.configuration/4.block/definitions.md
index 6a56f957..1544a3ef 100644
--- a/docs/website/content/2.configuration/4.block/definitions.md
+++ b/docs/website/content/2.configuration/4.block/definitions.md
@@ -6,4 +6,4 @@ Use the `definitions` block to define configurations you want to reuse.
 
 | Block name    | Context | Label    | Nested block(s)                                                                                                                                                                                                                                                            |
 |:--------------|:--------|:---------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `definitions` | -       | no label | [Backend Block(s)](backend), [Basic Auth Block(s)](basic_auth), [JWT Block(s)](jwt), [JWT Signing Profile Block(s)](jwt_signing_profile), [SAML Block(s)](saml), [OAuth2 AC Block(s)](oauth2), [OIDC Block(s)](oidc) |
+| `definitions` | -       | no label | [Backend Block(s)](backend), [Basic Auth Block(s)](basic_auth), [JWT Block(s)](jwt), [JWT Signing Profile Block(s)](jwt_signing_profile), [SAML Block(s)](saml), [OAuth2 AC Block(s)](oauth2), [OIDC Block(s)](oidc), [Proxy Block(s)](proxy) |
diff --git a/docs/website/content/2.configuration/4.block/endpoint.md b/docs/website/content/2.configuration/4.block/endpoint.md
index 101927b2..ca82968e 100644
--- a/docs/website/content/2.configuration/4.block/endpoint.md
+++ b/docs/website/content/2.configuration/4.block/endpoint.md
@@ -100,6 +100,12 @@ values: [
     "default": "",
     "description": "Location of the error file template."
   },
+  {
+    "name": "proxy",
+    "type": "string",
+    "default": "",
+    "description": "proxy block reference"
+  },
   {
     "name": "remove_form_params",
     "type": "object",
diff --git a/docs/website/content/2.configuration/4.block/proxy.md b/docs/website/content/2.configuration/4.block/proxy.md
index b249108f..67fb52bd 100644
--- a/docs/website/content/2.configuration/4.block/proxy.md
+++ b/docs/website/content/2.configuration/4.block/proxy.md
@@ -6,8 +6,9 @@ The `proxy` block creates and executes a proxy request to a backend service.
 
 | Block name | Context                           | Label                                                                                                                                                                                                                                          | Nested block(s)                                                                                                                                                                                                                                |
 |:-----------|:----------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `proxy`    | [Endpoint Block](endpoint) | &#9888; A `proxy` block or [Request Block](request) w/o a label has an implicit label `"default"`. Only **one** `proxy` block or [Request Block](request) w/ label `"default"` per [Endpoint Block](endpoint) is allowed. | [Backend Block](backend) (&#9888; required, if no [Backend Block](backend) reference is defined or no `url` attribute is set.), [Websockets Block](websockets) (&#9888; Either websockets attribute or block is allowed.) |
+| `proxy`    | [Endpoint Block](endpoint) | See `Label` description below. | [Backend Block](backend) (&#9888; required, if no [Backend Block](backend) reference is defined or no `url` attribute is set.), [Websockets Block](websockets) (&#9888; Either websockets attribute or block is allowed.) |
 
+**Label:** If defined in an [Endpoint Block](endpoint), a `proxy` block or [Request Block](request) w/o a label has an implicit name `"default"`. If defined in the [Definitions Block](definitions), the label of `proxy` is used as reference in [Endpoint Blocks](endpoint) and the name can be defined via `name` attribute. Only **one** `proxy` block or [Request Block](request) w/ label `"default"` per [Endpoint Block](endpoint) is allowed. 
 
 ::attributes
 ---
@@ -48,6 +49,12 @@ values: [
     "default": "[]",
     "description": "If defined, the response status code will be verified against this list of codes. If the status code not included in this list an `unexpected_status` error will be thrown which can be handled with an [`error_handler`](error_handler)."
   },
+  {
+    "name": "name",
+    "type": "string",
+    "default": "default",
+    "description": "Defines the proxy request name. Allowed only in the [Definitions Block](definitions)."
+  },
   {
     "name": "remove_form_params",
     "type": "object",


Test patch:
diff --git a/config/configload/validate_test.go b/config/configload/validate_test.go
index 9756fc37..7f1980a1 100644
--- a/config/configload/validate_test.go
+++ b/config/configload/validate_test.go
@@ -284,6 +284,17 @@ func Test_validateBody(t *testing.T) {
 			 }`,
 			"couper.hcl:5,15-20: backend labels must be unique; ",
 		},
+		{
+			"duplicate proxy labels",
+			`server {}
+			 definitions {
+			   proxy "foo" {
+			   }
+			   proxy "foo" {
+			   }
+			 }`,
+			"couper.hcl:5,13-18: proxy labels must be unique; ",
+		},
 		{
 			"missing basic_auth label",
 			`server {}
diff --git a/main_test.go b/main_test.go
index 3d50ee25..30a838d4 100644
--- a/main_test.go
+++ b/main_test.go
@@ -52,6 +52,8 @@ func Test_realmain(t *testing.T) {
 		{"invalid method in allowed_methods in endpoint", []string{"couper", "run", "-f", base + "/14_couper.hcl"}, nil, `level=error msg="%s/14_couper.hcl:3,5-35: method contains invalid character(s); " build=dev`, 1},
 		{"invalid method in allowed_methods in api", []string{"couper", "run", "-f", base + "/15_couper.hcl"}, nil, `level=error msg="%s/15_couper.hcl:3,5-35: method contains invalid character(s); " build=dev`, 1},
 		{"rate_limit block in anonymous backend", []string{"couper", "run", "-f", base + "/17_couper.hcl"}, nil, `level=error msg="configuration error: anonymous_3_11: anonymous backend (\"anonymous_3_11\") cannot define 'beta_rate_limit' block(s)" build=dev`, 1},
+		{"non-string proxy reference", []string{"couper", "run", "-f", base + "/19_couper.hcl"}, nil, `level=error msg="%s/19_couper.hcl:3,13-14: proxy must evaluate to string; " build=dev`, 1},
+		{"proxy reference does not exist", []string{"couper", "run", "-f", base + "/20_couper.hcl"}, nil, `level=error msg="%s/20_couper.hcl:3,14-17: proxy reference is not defined; " build=dev`, 1},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
diff --git a/server/http_endpoints_test.go b/server/http_endpoints_test.go
index b3041c99..99c7e861 100644
--- a/server/http_endpoints_test.go
+++ b/server/http_endpoints_test.go
@@ -961,3 +961,52 @@ func TestEndpointACBufferOptions(t *testing.T) {
 		})
 	}
 }
+
+func TestEndpoint_ReusableProxies(t *testing.T) {
+	client := test.NewHTTPClient()
+	helper := test.New(t)
+
+	shutdown, hook := newCouper(filepath.Join(testdataPath, "18_couper.hcl"), helper)
+	defer shutdown()
+
+	type testCase struct {
+		path      string
+		name      string
+		expStatus int
+	}
+
+	for _, tc := range []testCase{
+		{"/abcdef", "abcdef", 204},
+		{"/reuse", "abcdef", 204},
+		{"/default", "default", 200},
+		{"/api-abcdef", "abcdef", 204},
+		{"/api-reuse", "abcdef", 204},
+		{"/api-default", "default", 200},
+	} {
+		t.Run(tc.path, func(st *testing.T) {
+			h := test.New(st)
+
+			req, err := http.NewRequest(http.MethodGet, "http://example.com:8080"+tc.path, nil)
+			h.Must(err)
+
+			hook.Reset()
+
+			res, err := client.Do(req)
+			h.Must(err)
+
+			if res.StatusCode != tc.expStatus {
+				st.Errorf("want: %d, got: %d", tc.expStatus, res.StatusCode)
+			}
+
+			for _, e := range hook.AllEntries() {
+				if e.Data["type"] != "couper_backend" {
+					continue
+				}
+
+				if name := e.Data["request"].(logging.Fields)["name"]; name != tc.name {
+					st.Errorf("want: %s, got: %s", tc.name, name)
+				}
+			}
+		})
+	}
+}
diff --git a/server/testdata/endpoints/18_couper.hcl b/server/testdata/endpoints/18_couper.hcl
new file mode 100644
index 00000000..6f9cde3a
--- /dev/null
+++ b/server/testdata/endpoints/18_couper.hcl
@@ -0,0 +1,48 @@
+server "couper" {
+  endpoint "/abcdef" {
+    proxy = "test"
+    response {
+      status = 204
+    }
+  }
+  endpoint "/reuse" {
+    proxy = "test"
+    response {
+      status = 204
+    }
+  }
+
+  endpoint "/default" {
+    proxy = "defaultName"
+  }
+
+  api {
+    endpoint "/api-abcdef" {
+      proxy = "test"
+      response {
+        status = 204
+      }
+    }
+    endpoint "/api-reuse" {
+      proxy = "test"
+      response {
+        status = 204
+      }
+    }
+
+    endpoint "/api-default" {
+      proxy = "defaultName"
+    }
+  }
+}
+
+definitions {
+  proxy "defaultName" {
+    url = "${env.COUPER_TEST_BACKEND_ADDR}/anything"
+  }
+
+  proxy "test" {
+    name = "abcdef"
+    url = "${env.COUPER_TEST_BACKEND_ADDR}/anything"
+  }
+}
diff --git a/server/testdata/settings/19_couper.hcl b/server/testdata/settings/19_couper.hcl
new file mode 100644
index 00000000..fa263b95
--- /dev/null
+++ b/server/testdata/settings/19_couper.hcl
@@ -0,0 +1,5 @@
+server {
+  endpoint "/" {
+    proxy = 1
+  }
+}
diff --git a/server/testdata/settings/20_couper.hcl b/server/testdata/settings/20_couper.hcl
new file mode 100644
index 00000000..cdbbc506
--- /dev/null
+++ b/server/testdata/settings/20_couper.hcl
@@ -0,0 +1,5 @@
+server {
+  endpoint "/" {
+    proxy = "foo"
+  }
+}