Repository: avenga/couper
Pull request #90 (2021-01-13 13:35:21)

Add free endpoints


Patch:
diff --git a/config/configload/load.go b/config/configload/load.go
index e8690523..017cd08e 100644
--- a/config/configload/load.go
+++ b/config/configload/load.go
@@ -118,6 +118,12 @@ func LoadConfig(body hcl.Body, src []byte) (*config.CouperFile, error) {
 
 		file.Server = append(file.Server, srv)
 
+		serverBodies, err := mergeBackendBodies(backends, srv)
+		if err != nil {
+			return nil, err
+		}
+		srv.Remain = MergeBodies(serverBodies)
+
 		// api block(s)
 		for _, apiBlock := range []*config.Api{srv.API} {
 			if apiBlock == nil {
@@ -129,6 +135,8 @@ func LoadConfig(body hcl.Body, src []byte) (*config.CouperFile, error) {
 				return nil, err
 			}
 
+			bodies = appendUniqueBodies(serverBodies, bodies...)
+
 			// empty bodies would be removed with a hcl.Merge.. later on.
 			if err = refineEndpoints(backends, bodies, apiBlock.Endpoints); err != nil {
 				return nil, err
@@ -136,10 +144,9 @@ func LoadConfig(body hcl.Body, src []byte) (*config.CouperFile, error) {
 		}
 
 		// standalone endpoints
-		// TODO: free endpoints
-		//if err := refineEndpoints(file.Definitions, nil, srv.Endpoints); err != nil {
-		//	return nil, err
-		//}
+		if err := refineEndpoints(backends, nil, srv.Endpoints); err != nil {
+			return nil, err
+		}
 	}
 
 	if len(file.Server) == 0 {
@@ -172,7 +179,7 @@ func mergeBackendBodies(backendList Backends, inlineBackend config.Inline) ([]hc
 			return nil, fmt.Errorf("configuration error: inlineBackend reference and inline definition")
 		}
 		// we have a reference, append to list and...
-		bodies = append(bodies, reference)
+		bodies = appendUniqueBodies(bodies, reference)
 	}
 	// ...additionally add the inline overrides.
 	if content != nil && len(content.Attributes) > 0 {
@@ -198,7 +205,7 @@ func mergeBackendBodies(backendList Backends, inlineBackend config.Inline) ([]hc
 			bodies = append([]hcl.Body{ref}, bodies...)
 		}
 
-		bodies = append(bodies, backends[0].Body)
+		bodies = appendUniqueBodies(bodies, backends[0].Body)
 	}
 
 	return bodies, nil
diff --git a/config/runtime/server.go b/config/runtime/server.go
index 2dcc4e55..ccf4791e 100644
--- a/config/runtime/server.go
+++ b/config/runtime/server.go
@@ -48,10 +48,13 @@ type HandlerKind uint8
 
 const (
 	KindAPI HandlerKind = iota
+	KindEndpoint
 	KindFiles
 	KindSPA
 )
 
+type endpointList map[*config.Endpoint]HandlerKind
+
 // NewServerConfiguration sets http handler specific defaults and validates the given gateway configuration.
 // Wire up all endpoints and maps them within the returned Server.
 func NewServerConfiguration(conf *config.CouperFile, log *logrus.Entry) (ServerConfiguration, error) {
@@ -125,59 +128,85 @@ func NewServerConfiguration(conf *config.CouperFile, log *logrus.Entry) (ServerC
 			}
 		}
 
-		if srvConf.API != nil {
-			// map backends to endpoint
-			endpoints := make(map[string]bool)
+		endpointsPatterns := make(map[string]bool)
+
+		for endpoint, epType := range getEndpointsList(srvConf) {
+			var basePath string
+			var cors *config.CORS
+			var errTpl *errors.Template
+
+			switch epType {
+			case KindAPI:
+				basePath = serverOptions.APIBasePath
+				cors = srvConf.API.CORS
+				errTpl = serverOptions.APIErrTpl
+			case KindEndpoint:
+				basePath = serverOptions.SrvBasePath
+				errTpl = serverOptions.ServerErrTpl
+			}
 
-			for _, endpoint := range srvConf.API.Endpoints {
-				pattern := utils.JoinPath("/", serverOptions.APIBasePath, endpoint.Pattern)
+			pattern := utils.JoinPath(basePath, endpoint.Pattern)
+			unique, cleanPattern := isUnique(endpointsPatterns, pattern)
+			if !unique {
+				return nil, fmt.Errorf("%s: duplicate endpoint: '%s'", endpoint.Body().MissingItemRange().String(), pattern)
+			}
+			endpointsPatterns[cleanPattern] = true
 
-				unique, cleanPattern := isUnique(endpoints, pattern)
-				if !unique {
-					return nil, fmt.Errorf("duplicate endpoint: %q", pattern)
-				}
-				endpoints[cleanPattern] = true
-
-				// setACHandlerFn individual wrap for access_control configuration per endpoint
-				setACHandlerFn := func(protectedHandler http.Handler) {
-					api[endpoint] = configureProtectedHandler(accessControls, serverOptions.APIErrTpl,
-						config.NewAccessControl(srvConf.AccessControl, srvConf.DisableAccessControl).
-							Merge(config.NewAccessControl(srvConf.API.AccessControl, srvConf.API.DisableAccessControl)),
-						config.NewAccessControl(endpoint.AccessControl, endpoint.DisableAccessControl),
-						protectedHandler)
+			// setACHandlerFn individual wrap for access_control configuration per endpoint
+			setACHandlerFn := func(protectedHandler http.Handler) {
+				ac := config.NewAccessControl(srvConf.AccessControl, srvConf.DisableAccessControl)
+				if epType == KindAPI {
+					ac = ac.Merge(config.NewAccessControl(srvConf.API.AccessControl, srvConf.API.DisableAccessControl))
 				}
 
-				backendConf := *DefaultBackendConf
-				if diags := gohcl.DecodeBody(endpoint.Remain, confCtx, &backendConf); diags.HasErrors() {
-					return nil, diags
-				}
+				api[endpoint] = configureProtectedHandler(accessControls, serverOptions.APIErrTpl,
+					ac,
+					config.NewAccessControl(endpoint.AccessControl, endpoint.DisableAccessControl),
+					protectedHandler)
+			}
 
-				backend, err := newProxy(confCtx, &backendConf, srvConf.API.CORS, log, serverOptions, conf.Settings.NoProxyFromEnv)
-				if err != nil {
-					return nil, err
-				}
+			backendConf := *DefaultBackendConf
+			if diags := gohcl.DecodeBody(endpoint.Remain, confCtx, &backendConf); diags.HasErrors() {
+				return nil, diags
+			}
 
-				setACHandlerFn(backend)
+			backend, err := newProxy(
+				confCtx, &backendConf, cors, log, serverOptions,
+				conf.Settings.NoProxyFromEnv, errTpl, epType,
+			)
+			if err != nil {
+				return nil, err
+			}
 
-				err = setRoutesFromHosts(serverConfiguration, defaultPort, srvConf.Hosts, pattern, api[endpoint], KindAPI)
-				if err != nil {
-					return nil, err
-				}
+			setACHandlerFn(backend)
+
+			err = setRoutesFromHosts(serverConfiguration, defaultPort, srvConf.Hosts, pattern, api[endpoint], KindAPI)
+			if err != nil {
+				return nil, err
 			}
 		}
 	}
+
 	return serverConfiguration, nil
 }
 
 func newProxy(
-	ctx *hcl.EvalContext, beConf *config.Backend, corsOpts *config.CORS,
-	log *logrus.Entry, srvOpts *server.Options, noProxyFromEnv bool) (http.Handler, error) {
+	ctx *hcl.EvalContext, beConf *config.Backend, corsOpts *config.CORS, log *logrus.Entry,
+	srvOpts *server.Options, noProxyFromEnv bool, errTpl *errors.Template, epType HandlerKind) (http.Handler, error) {
 	corsOptions, err := handler.NewCORSOptions(corsOpts)
 	if err != nil {
 		return nil, err
 	}
 
-	proxyOptions, err := handler.NewProxyOptions(beConf, corsOptions, noProxyFromEnv)
+	var kind string
+	switch epType {
+	case KindAPI:
+		kind = "api"
+	case KindEndpoint:
+		kind = "endpoint"
+	}
+
+	proxyOptions, err := handler.NewProxyOptions(beConf, corsOptions, noProxyFromEnv, errTpl, kind)
 	if err != nil {
 		return nil, err
 	}
@@ -314,6 +343,8 @@ func setRoutesFromHosts(srvConf ServerConfiguration, defaultPort int, hosts []st
 
 		switch kind {
 		case KindAPI:
+			fallthrough
+		case KindEndpoint:
 			routes = srvConf[listenPort].EndpointRoutes
 		case KindFiles:
 			routes = srvConf[listenPort].FileRoutes
@@ -330,3 +361,19 @@ func setRoutesFromHosts(srvConf ServerConfiguration, defaultPort int, hosts []st
 	}
 	return nil
 }
+
+func getEndpointsList(srvConf *config.Server) endpointList {
+	endpoints := make(endpointList)
+
+	if srvConf.API != nil {
+		for _, endpoint := range srvConf.API.Endpoints {
+			endpoints[endpoint] = KindAPI
+		}
+	}
+
+	for _, endpoint := range srvConf.Endpoints {
+		endpoints[endpoint] = KindEndpoint
+	}
+
+	return endpoints
+}
diff --git a/config/runtime/server/options.go b/config/runtime/server/options.go
index 697472c5..b4331de2 100644
--- a/config/runtime/server/options.go
+++ b/config/runtime/server/options.go
@@ -19,6 +19,7 @@ type Options struct {
 	APIBasePath  string
 	FileBasePath string
 	SPABasePath  string
+	SrvBasePath  string
 	ServerName   string
 }
 
@@ -33,6 +34,7 @@ func NewServerOptions(conf *config.Server) (*Options, error) {
 		return options, nil
 	}
 	options.ServerName = conf.Name
+	options.SrvBasePath = path.Join("/", conf.BasePath)
 
 	if conf.ErrorFile != "" {
 		tpl, err := errors.NewTemplateFromFile(conf.ErrorFile)
@@ -44,7 +46,7 @@ func NewServerOptions(conf *config.Server) (*Options, error) {
 	}
 
 	if conf.API != nil {
-		options.APIBasePath = path.Join("/", conf.BasePath, conf.API.BasePath)
+		options.APIBasePath = path.Join(options.SrvBasePath, conf.API.BasePath)
 
 		if conf.API.ErrorFile != "" {
 			tpl, err := errors.NewTemplateFromFile(conf.API.ErrorFile)
@@ -64,11 +66,11 @@ func NewServerOptions(conf *config.Server) (*Options, error) {
 			options.FileErrTpl = tpl
 		}
 
-		options.FileBasePath = utils.JoinPath("/", conf.BasePath, conf.Files.BasePath)
+		options.FileBasePath = utils.JoinPath(options.SrvBasePath, conf.Files.BasePath)
 	}
 
 	if conf.Spa != nil {
-		options.SPABasePath = utils.JoinPath("/", conf.BasePath, conf.Spa.BasePath)
+		options.SPABasePath = utils.JoinPath(options.SrvBasePath, conf.Spa.BasePath)
 	}
 
 	return options, nil
diff --git a/config/server.go b/config/server.go
index aaa08a4c..157e9100 100644
--- a/config/server.go
+++ b/config/server.go
@@ -1,13 +1,51 @@
 package config
 
+import (
+	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/gohcl"
+)
+
+var _ Inline = &Server{}
+
+// Server represents the HCL <server> block.
 type Server struct {
-	AccessControl        []string `hcl:"access_control,optional"`
-	DisableAccessControl []string `hcl:"disable_access_control,optional"`
-	API                  *Api     `hcl:"api,block"`
-	BasePath             string   `hcl:"base_path,optional"`
-	ErrorFile            string   `hcl:"error_file,optional"`
-	Files                *Files   `hcl:"files,block"`
-	Hosts                []string `hcl:"hosts,optional"`
-	Name                 string   `hcl:"name,label"`
-	Spa                  *Spa     `hcl:"spa,block"`
+	AccessControl        []string  `hcl:"access_control,optional"`
+	DisableAccessControl []string  `hcl:"disable_access_control,optional"`
+	API                  *Api      `hcl:"api,block"`
+	Backend              string    `hcl:"backend,optional"`
+	BasePath             string    `hcl:"base_path,optional"`
+	Endpoints            Endpoints `hcl:"endpoint,block"`
+	ErrorFile            string    `hcl:"error_file,optional"`
+	Files                *Files    `hcl:"files,block"`
+	Hosts                []string  `hcl:"hosts,optional"`
+	Name                 string    `hcl:"name,label"`
+	Remain               hcl.Body  `hcl:",remain"`
+	Spa                  *Spa      `hcl:"spa,block"`
+}
+
+func (s Server) Body() hcl.Body {
+	return s.Remain
+}
+
+func (s Server) Reference() string {
+	return s.Backend
+}
+
+func (s Server) Schema(inline bool) *hcl.BodySchema {
+	if !inline {
+		schema, _ := gohcl.ImpliedBodySchema(s)
+		return schema
+	}
+
+	type Inline struct {
+		Backend *Backend `hcl:"backend,block"`
+	}
+	schema, _ := gohcl.ImpliedBodySchema(&Inline{})
+
+	// The Server contains a backend reference, backend block is not allowed.
+	if s.Backend != "" {
+		schema.Blocks = nil
+	}
+
+	return newBackendSchema(schema, s.Body())
 }
diff --git a/docs/README.md b/docs/README.md
index bbc8aa3f..c4b0159d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -274,7 +274,7 @@ Endpoints define the entry points of Couper. The mandatory *label* defines the p
 
 | Name | Description                           |
 |:-------------------|:--------------------------------------|
-|context|`api` block|
+|context|`server` and `api` block|
 |*label*|<ul><li>&#9888; mandatory</li><li>defines the path suffix for incoming client requests</li><li>*example:* `endpoint "/dashboard" { `</li><li>incoming client request: `example.com/api/dashboard`</li></ul>|
 | `path`|<ul><li>changeable part of upstream URL</li><li>changes the path suffix of the outgoing request</li></ul>|
 |[**`access_control`**](#access_control_attribute)|sets predefined `access_control` for `endpoint`|
diff --git a/errors/code.go b/errors/code.go
index 8ee9f525..3556a5c2 100644
--- a/errors/code.go
+++ b/errors/code.go
@@ -38,6 +38,13 @@ const (
 	UpstreamResponseBufferingFailed
 )
 
+const (
+	EndpointError Code = 7000 + iota
+	EndpointConnect
+	EndpointProxyConnect
+	EndpointReqBodySizeExceeded
+)
+
 var codes = map[Code]string{
 	// 1xxx
 	Server:         "Server error",
diff --git a/handler/proxy.go b/handler/proxy.go
index 9d3f586b..e155f659 100644
--- a/handler/proxy.go
+++ b/handler/proxy.go
@@ -213,7 +213,7 @@ func (p *Proxy) roundtrip(rw http.ResponseWriter, req *http.Request) {
 	err := p.Director(outreq)
 	if err != nil {
 		roundtripInfo.Err = err
-		p.srvOptions.APIErrTpl.ServeError(err).ServeHTTP(rw, req)
+		p.options.ErrorTemplate.ServeError(err).ServeHTTP(rw, req)
 		return
 	}
 
@@ -250,7 +250,7 @@ func (p *Proxy) roundtrip(rw http.ResponseWriter, req *http.Request) {
 	if p.options.OpenAPI != nil {
 		apiValidator = NewOpenAPIValidator(p.options.OpenAPI)
 		if roundtripInfo.Err = apiValidator.ValidateRequest(outreq, roundtripInfo); roundtripInfo.Err != nil {
-			p.srvOptions.APIErrTpl.ServeError(couperErr.UpstreamRequestValidationFailed).ServeHTTP(rw, req)
+			p.options.ErrorTemplate.ServeError(couperErr.UpstreamRequestValidationFailed).ServeHTTP(rw, req)
 			return
 		}
 	}
@@ -263,7 +263,7 @@ func (p *Proxy) roundtrip(rw http.ResponseWriter, req *http.Request) {
 		if strings.HasPrefix(err.Error(), "proxyconnect") {
 			errCode = couperErr.APIProxyConnect
 		}
-		p.srvOptions.APIErrTpl.ServeError(errCode).ServeHTTP(rw, req)
+		p.options.ErrorTemplate.ServeError(p.getErrorCode(errCode)).ServeHTTP(rw, req)
 		return
 	}
 
@@ -297,7 +297,7 @@ func (p *Proxy) roundtrip(rw http.ResponseWriter, req *http.Request) {
 	if apiValidator != nil {
 		roundtripInfo.Err = apiValidator.ValidateResponse(res, roundtripInfo)
 		if roundtripInfo.Err != nil {
-			p.srvOptions.APIErrTpl.ServeError(couperErr.UpstreamResponseValidationFailed).ServeHTTP(rw, req)
+			p.options.ErrorTemplate.ServeError(couperErr.UpstreamResponseValidationFailed).ServeHTTP(rw, req)
 			return
 		}
 	}
@@ -570,7 +570,7 @@ func (p *Proxy) SetGetBody(req *http.Request) error {
 		}
 
 		if n > p.options.RequestBodyLimit {
-			return couperErr.APIReqBodySizeExceeded
+			return p.getErrorCode(couperErr.APIReqBodySizeExceeded)
 		}
 
 		bodyBytes := buf.Bytes()
@@ -744,7 +744,24 @@ func (p *Proxy) Options() *server.Options {
 }
 
 func (p *Proxy) String() string {
-	return "api"
+	return p.options.Kind
+}
+
+func (p *Proxy) getErrorCode(code couperErr.Code) couperErr.Code {
+	if p.options.Kind == "endpoint" {
+		switch code {
+		case couperErr.APIConnect:
+			return couperErr.EndpointConnect
+		case couperErr.APIProxyConnect:
+			return couperErr.EndpointProxyConnect
+		case couperErr.APIReqBodySizeExceeded:
+			return couperErr.EndpointReqBodySizeExceeded
+		}
+
+		return couperErr.EndpointError
+	}
+
+	return code
 }
 
 func setHeaderFields(header http.Header, options OptionsMap) {
diff --git a/handler/proxy_options.go b/handler/proxy_options.go
index d6bcc4e6..d8531803 100644
--- a/handler/proxy_options.go
+++ b/handler/proxy_options.go
@@ -9,6 +9,7 @@ import (
 	"github.com/hashicorp/hcl/v2"
 
 	"github.com/avenga/couper/config"
+	"github.com/avenga/couper/errors"
 )
 
 type ProxyOptions struct {
@@ -20,10 +21,15 @@ type ProxyOptions struct {
 	DisableCertValidation                bool
 	MaxConnections                       int
 	OpenAPI                              *OpenAPIValidatorOptions
+	ErrorTemplate                        *errors.Template
+	Kind                                 string
 	RequestBodyLimit                     int64
 }
 
-func NewProxyOptions(conf *config.Backend, corsOpts *CORSOptions, noProxyFromEnv bool) (*ProxyOptions, error) {
+func NewProxyOptions(
+	conf *config.Backend, corsOpts *CORSOptions, noProxyFromEnv bool,
+	errTpl *errors.Template, kind string,
+) (*ProxyOptions, error) {
 	var timeout, connTimeout, ttfbTimeout time.Duration
 	if err := parseDuration(conf.Timeout, &timeout); err != nil {
 		return nil, err
@@ -59,6 +65,8 @@ func NewProxyOptions(conf *config.Backend, corsOpts *CORSOptions, noProxyFromEnv
 		MaxConnections:        conf.MaxConnections,
 		NoProxyFromEnv:        noProxyFromEnv,
 		OpenAPI:               openAPIValidatorOptions,
+		ErrorTemplate:         errTpl,
+		Kind:                  kind,
 		RequestBodyLimit:      bodyLimit,
 		TTFBTimeout:           ttfbTimeout,
 		Timeout:               timeout,


Test patch:
diff --git a/config/runtime/server_internal_test.go b/config/runtime/server_internal_test.go
index ce2c2590..02eed1f9 100644
--- a/config/runtime/server_internal_test.go
+++ b/config/runtime/server_internal_test.go
@@ -5,6 +5,9 @@ import (
 	"testing"
 
 	"github.com/avenga/couper/config"
+	"github.com/avenga/couper/internal/seetie"
+	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/hcltest"
 )
 
 func TestServer_isUnique(t *testing.T) {
@@ -165,3 +168,54 @@ func TestServer_splitWildcardHostPort(t *testing.T) {
 		t.Errorf("Expected NIL, given %s", err)
 	}
 }
+
+func TestServer_getEndpointsList(t *testing.T) {
+	getHCLBody := func(in string) hcl.Body {
+		return hcltest.MockBody(&hcl.BodyContent{
+			Attributes: hcltest.MockAttrs(map[string]hcl.Expression{
+				"path": hcltest.MockExprLiteral(seetie.GoToValue(in)),
+			}),
+		})
+	}
+
+	srvConf := &config.Server{
+		API: &config.Api{
+			Endpoints: []*config.Endpoint{
+				{Remain: getHCLBody("/api/1")},
+				{Remain: getHCLBody("/api/2")},
+			},
+		},
+		Endpoints: []*config.Endpoint{
+			{Remain: getHCLBody("/free/1")},
+			{Remain: getHCLBody("/free/2")},
+		},
+	}
+
+	endpoints := getEndpointsList(srvConf)
+	if l := len(endpoints); l != 4 {
+		t.Fatalf("Expected 4 endpointes, given %d", l)
+	}
+
+	checks := map[string]HandlerKind{
+		"/api/1":  KindAPI,
+		"/api/2":  KindAPI,
+		"/free/1": KindEndpoint,
+		"/free/2": KindEndpoint,
+	}
+
+	for e, kind := range endpoints {
+		a, _ := e.Remain.JustAttributes()
+		v, _ := a["path"].Expr.Value(nil)
+		path := seetie.ValueToString(v)
+
+		if v, ok := checks[path]; !ok || v != kind {
+			t.Fatalf("Missing an endpoint for %s", path)
+		}
+
+		delete(checks, path)
+	}
+
+	if l := len(checks); l != 0 {
+		t.Fatalf("Expected 0 checks, given %d", l)
+	}
+}
diff --git a/handler/openapi_validator_test.go b/handler/openapi_validator_test.go
index cf542db3..ce516a99 100644
--- a/handler/openapi_validator_test.go
+++ b/handler/openapi_validator_test.go
@@ -55,13 +55,13 @@ func TestOpenAPIValidator_ValidateRequest(t *testing.T) {
 				Expr: hcltest.MockExprLiteral(cty.StringVal(origin.URL)),
 			},
 		}}),
-		OpenAPI: []*config.OpenAPI{&config.OpenAPI{
+		OpenAPI: []*config.OpenAPI{{
 			File: filepath.Join("testdata/validation/backend_01_openapi.yaml"),
 		}},
 		RequestBodyLimit: "64MiB",
 	}
 
-	proxyOpts, err := handler.NewProxyOptions(beConf, &handler.CORSOptions{}, config.DefaultSettings.NoProxyFromEnv)
+	proxyOpts, err := handler.NewProxyOptions(beConf, &handler.CORSOptions{}, config.DefaultSettings.NoProxyFromEnv, errors.DefaultJSON, "api")
 	helper.Must(err)
 
 	backend, err := handler.NewProxy(proxyOpts, log.WithContext(context.Background()), &server.Options{APIErrTpl: errors.DefaultJSON}, eval.NewENVContext(nil))
diff --git a/handler/proxy_test.go b/handler/proxy_test.go
index f4f0db94..03ff4efd 100644
--- a/handler/proxy_test.go
+++ b/handler/proxy_test.go
@@ -40,16 +40,18 @@ func TestProxy_ServeHTTP_Timings(t *testing.T) {
 	}))
 	defer origin.Close()
 
+	tpl, _ := errors.NewTemplate("text/plain", []byte("error"))
+
 	tests := []struct {
 		name           string
 		options        *handler.ProxyOptions
 		req            *http.Request
 		expectedStatus int
 	}{
-		{"with zero timings", &handler.ProxyOptions{Context: test.NewRemainContext("origin", origin.URL)}, httptest.NewRequest(http.MethodGet, "http://1.2.3.4/", nil), http.StatusNoContent},
-		{"with overall timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", "http://1.2.3.4/"), Timeout: time.Second}, httptest.NewRequest(http.MethodGet, "http://1.2.3.5/", nil), http.StatusBadGateway},
-		{"with connect timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", "http://blackhole.webpagetest.org/"), ConnectTimeout: time.Second}, httptest.NewRequest(http.MethodGet, "http://1.2.3.6/", nil), http.StatusBadGateway},
-		{"with ttfb timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", origin.URL), TTFBTimeout: time.Second}, httptest.NewRequest(http.MethodHead, "http://1.2.3.7/", nil), http.StatusBadGateway},
+		{"with zero timings", &handler.ProxyOptions{Context: test.NewRemainContext("origin", origin.URL), ErrorTemplate: tpl}, httptest.NewRequest(http.MethodGet, "http://1.2.3.4/", nil), http.StatusNoContent},
+		{"with overall timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", "http://1.2.3.4/"), ErrorTemplate: tpl, Timeout: time.Second}, httptest.NewRequest(http.MethodGet, "http://1.2.3.5/", nil), http.StatusBadGateway},
+		{"with connect timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", "http://blackhole.webpagetest.org/"), ErrorTemplate: tpl, ConnectTimeout: time.Second}, httptest.NewRequest(http.MethodGet, "http://1.2.3.6/", nil), http.StatusBadGateway},
+		{"with ttfb timeout", &handler.ProxyOptions{Context: test.NewRemainContext("origin", origin.URL), ErrorTemplate: tpl, TTFBTimeout: time.Second}, httptest.NewRequest(http.MethodHead, "http://1.2.3.7/", nil), http.StatusBadGateway},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -462,6 +464,8 @@ func TestProxy_ServeHTTP_Validation(t *testing.T) {
 
 	logger, hook := logrustest.NewNullLogger()
 
+	tpl, _ := errors.NewTemplate("text/plain", []byte("error"))
+
 	for _, tt := range tests {
 		t.Run(tt.name, func(subT *testing.T) {
 
@@ -472,7 +476,7 @@ func TestProxy_ServeHTTP_Validation(t *testing.T) {
 			content := helper.NewProxyContext(`
 				origin = "` + origin.URL + `"
 			`)
-			p, err := handler.NewProxy(&handler.ProxyOptions{Context: content, OpenAPI: openapiValidatorOptions}, logger.WithContext(context.Background()), srvOpts, eval.NewENVContext(nil))
+			p, err := handler.NewProxy(&handler.ProxyOptions{Context: content, OpenAPI: openapiValidatorOptions, ErrorTemplate: tpl}, logger.WithContext(context.Background()), srvOpts, eval.NewENVContext(nil))
 			if err != nil {
 				subT.Fatal(err)
 			}
@@ -712,7 +716,7 @@ func TestProxy_SetGetBody_LimitBody_Roundtrip(t *testing.T) {
 			proxyOpts, err := handler.NewProxyOptions(&config.Backend{
 				Remain:           helper.NewProxyContext("set_request_headers = { x = req.post }"), // ensure buffering is enabled
 				RequestBodyLimit: testcase.limit,
-			}, nil, config.DefaultSettings.NoProxyFromEnv)
+			}, nil, config.DefaultSettings.NoProxyFromEnv, nil, "api")
 			if err != nil {
 				subT.Error(err)
 				return
diff --git a/server/http_integration_test.go b/server/http_integration_test.go
index 4e088437..8bd35414 100644
--- a/server/http_integration_test.go
+++ b/server/http_integration_test.go
@@ -628,6 +628,12 @@ func TestHTTPServer_QueryParams(t *testing.T) {
 			},
 			Path: "/xxx",
 		}},
+		{"09_couper.hcl", "", expectation{
+			Query: url.Values{
+				"test": []string{"pest"},
+			},
+			Path: "/",
+		}},
 	} {
 		shutdown, _ := newCouper(path.Join(confPath, tc.file), test.New(t))
 
@@ -797,6 +803,28 @@ func TestHTTPServer_QueryEncoding(t *testing.T) {
 	shutdown()
 }
 
+func TestHTTPServer_Backends(t *testing.T) {
+	client := newClient()
+
+	config := "testdata/integration/config/02_couper.hcl"
+
+	shutdown, _ := newCouper(config, test.New(t))
+	defer shutdown()
+
+	helper := test.New(t)
+
+	req, err := http.NewRequest(http.MethodGet, "http://example.com:8080/", nil)
+	helper.Must(err)
+
+	res, err := client.Do(req)
+	helper.Must(err)
+
+	exp := []string{"1", "2", "3", "4"}
+	if !reflect.DeepEqual(res.Header.Values("Foo"), exp) {
+		t.Errorf("\nwant: \n%#v\ngot: \n%#v", exp, res.Header.Values("Foo"))
+	}
+}
+
 func TestHTTPServer_TrailingSlash(t *testing.T) {
 	client := newClient()
 
@@ -1038,7 +1066,7 @@ func TestConfigBodyContentBackends(t *testing.T) {
 	}
 
 	for _, tc := range []testCase{
-		{"/anything", http.Header{"Foo": []string{"3"}}, url.Values{"bar": []string{"3", "4"}}},
+		{"/anything", http.Header{"Foo": []string{"4"}}, url.Values{"bar": []string{"3", "4"}}},
 		{"/get", http.Header{"Foo": []string{"1", "3"}}, url.Values{"bar": []string{"1", "3", "4"}}},
 	} {
 		t.Run(tc.path[1:], func(subT *testing.T) {
diff --git a/server/testdata/integration/config/02_couper.hcl b/server/testdata/integration/config/02_couper.hcl
index 84fc7b25..069663f0 100644
--- a/server/testdata/integration/config/02_couper.hcl
+++ b/server/testdata/integration/config/02_couper.hcl
@@ -1,9 +1,16 @@
 server "backends" {
+
+  backend "b" {
+    add_response_headers = {
+      foo = "2"
+    }
+  }
+
   api {
     backend "b" {
       origin = env.COUPER_TEST_BACKEND_ADDR
       add_response_headers = {
-        foo = "2"
+        foo = "3"
       }
       add_query_params = {
         bar = "2"
@@ -17,7 +24,7 @@ server "backends" {
       backend {
         origin = env.COUPER_TEST_BACKEND_ADDR
         add_response_headers = {
-          foo = "3"
+          foo = "4"
         }
         add_query_params = {
           bar = "4"
@@ -25,6 +32,14 @@ server "backends" {
       }
     }
 
+    endpoint "/" {
+      backend "b" {
+        add_response_headers = {
+          foo = "4"
+        }
+      }
+    }
+
     endpoint "/get" {
       add_query_params = {
         bar = "3"
diff --git a/server/testdata/integration/endpoint_eval/09_couper.hcl b/server/testdata/integration/endpoint_eval/09_couper.hcl
new file mode 100644
index 00000000..858e3124
--- /dev/null
+++ b/server/testdata/integration/endpoint_eval/09_couper.hcl
@@ -0,0 +1,11 @@
+server "free-endpoint" {
+  endpoint "/" {
+    backend {
+      set_query_params = {
+        test = "pest"
+      }
+
+      origin = env.COUPER_TEST_BACKEND_ADDR
+    }
+  }
+}