diff --git a/config/proxy.go b/config/proxy.go index 65d740ab2..cb6aef567 100644 --- a/config/proxy.go +++ b/config/proxy.go @@ -46,10 +46,11 @@ func (p Proxy) Inline() interface{} { meta.ResponseHeadersAttributes meta.FormParamsAttributes meta.QueryParamsAttributes - Backend *Backend `hcl:"backend,block" docs:"Configures a [backend](/configuration/block/backend) for the proxy request (zero or one). Mutually exclusive with {backend} attribute."` - ExpectedStatus []int `hcl:"expected_status,optional" docs:"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)."` - URL string `hcl:"url,optional" docs:"URL of the resource to request. May be relative to an origin specified in a referenced or nested {backend} block."` - Websockets *Websockets `hcl:"websockets,block" docs:"Configures support for [websockets](/configuration/block/websockets) connections (zero or one). Mutually exclusive with {websockets} attribute."` + Backend *Backend `hcl:"backend,block" docs:"Configures a [backend](/configuration/block/backend) for the proxy request (zero or one). Mutually exclusive with {backend} attribute."` + ExpectedStatus []int `hcl:"expected_status,optional" docs:"If defined, the response status code will be verified against this list of codes. If the status code is not included in this list an {unexpected_status} error will be thrown which can be handled with an [{error_handler}](error_handler). Mutually exclusive with {unexpected_status}."` + UnexpectedStatus []int `hcl:"unexpected_status,optional" docs:"If defined, the response status code will be verified against this list of codes. If the status code is included in this list an {unexpected_status} error will be thrown which can be handled with an [{error_handler}](error_handler). Mutually exclusive with {expected_status}."` + URL string `hcl:"url,optional" docs:"URL of the resource to request. May be relative to an origin specified in a referenced or nested {backend} block."` + Websockets *Websockets `hcl:"websockets,block" docs:"Configures support for [websockets](/configuration/block/websockets) connections (zero or one). Mutually exclusive with {websockets} attribute."` } return &Inline{} diff --git a/config/request/context_key.go b/config/request/context_key.go index 1499ebb0a..b0836061a 100644 --- a/config/request/context_key.go +++ b/config/request/context_key.go @@ -17,6 +17,7 @@ const ( EndpointExpectedStatus EndpointKind EndpointSequenceDependsOn + EndpointUnexpectedStatus Error GrantedPermissions Handler diff --git a/config/runtime/endpoint.go b/config/runtime/endpoint.go index 9332e52fa..b8077598f 100644 --- a/config/runtime/endpoint.go +++ b/config/runtime/endpoint.go @@ -121,6 +121,16 @@ func NewEndpointOptions(confCtx *hcl.EvalContext, endpointConf *config.Endpoint, var hasWSblock bool proxyBody := proxyConf.HCLBody() + _, hasExpStatus := proxyBody.Attributes["expected_status"] + _, hasUnexpStatus := proxyBody.Attributes["unexpected_status"] + if hasExpStatus && hasUnexpStatus { + r := proxyBody.SrcRange + return nil, hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "only one of expected_status and unexpected_status is allowed in a proxy block", + Subject: &r, + }} + } for _, b := range proxyBody.Blocks { if b.Type == "websockets" { hasWSblock = true diff --git a/docs/website/content/2.configuration/4.block/proxy.md b/docs/website/content/2.configuration/4.block/proxy.md index d15ac9fa0..c6f3442f6 100644 --- a/docs/website/content/2.configuration/4.block/proxy.md +++ b/docs/website/content/2.configuration/4.block/proxy.md @@ -45,7 +45,7 @@ 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).", + "description": "If defined, the response status code will be verified against this list of codes. If the status code is not included in this list an `unexpected_status` error will be thrown which can be handled with an [`error_handler`](error_handler). Mutually exclusive with `unexpected_status`.", "name": "expected_status", "type": "tuple (int)" }, @@ -103,6 +103,12 @@ values: [ "name": "set_response_headers", "type": "object" }, + { + "default": "[]", + "description": "If defined, the response status code will be verified against this list of codes. If the status code is included in this list an `unexpected_status` error will be thrown which can be handled with an [`error_handler`](error_handler). Mutually exclusive with `expected_status`.", + "name": "unexpected_status", + "type": "tuple (int)" + }, { "default": "", "description": "URL of the resource to request. May be relative to an origin specified in a referenced or nested `backend` block.", diff --git a/handler/producer/proxy_test.go b/handler/producer/proxy_test.go new file mode 100644 index 000000000..01f5c6651 --- /dev/null +++ b/handler/producer/proxy_test.go @@ -0,0 +1,114 @@ +package producer_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" + + "github.com/coupergateway/couper/errors" + "github.com/coupergateway/couper/eval" + "github.com/coupergateway/couper/handler" + "github.com/coupergateway/couper/handler/producer" + "github.com/coupergateway/couper/handler/transport" + "github.com/coupergateway/couper/internal/test" +) + +func Test_ProxyProduceUnexpectedStatus(t *testing.T) { + origin := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + s, err := strconv.Atoi(req.Header.Get("X-Status")) + if err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + rw.WriteHeader(s) + })) + defer origin.Close() + + logger, _ := test.NewLogger() + logEntry := logger.WithContext(context.Background()) + + backend := transport.NewBackend(&hclsyntax.Body{}, &transport.Config{Origin: origin.URL}, nil, logEntry) + + clientRequest, _ := http.NewRequest(http.MethodGet, "http://couper.local", nil) + + toListVal := func(numbers ...int64) cty.Value { + var list []cty.Value + for _, n := range numbers { + list = append(list, cty.NumberIntVal(n)) + } + return cty.ListVal(list) + } + + tests := []struct { + name string + attr *hclsyntax.Attribute + reflectStatus int // send via header, reflected by origin as http status-code + expectedErr error + }{ + {"/w status /w unexpected response", &hclsyntax.Attribute{ + Name: "unexpected_status", + Expr: &hclsyntax.LiteralValueExpr{Val: toListVal(200, 304)}}, + http.StatusNotModified, + errors.UnexpectedStatus, + }, + {"/w status /w expected response", &hclsyntax.Attribute{ + Name: "unexpected_status", + Expr: &hclsyntax.LiteralValueExpr{Val: toListVal(200, 304)}}, + http.StatusNotAcceptable, + nil, + }, + } + + for _, tt := range tests { + content := &hclsyntax.Body{Attributes: map[string]*hclsyntax.Attribute{ + "url": {Name: "url", Expr: &hclsyntax.LiteralValueExpr{Val: cty.StringVal(origin.URL)}}, + // Since request will not proxy our dynamic client-request header value, we will add a headers attr here. + // There is no validation, so this also applies to proxy (unused) + "set_request_headers": {Name: "set_request_headers", Expr: &hclsyntax.ObjectConsExpr{ + Items: []hclsyntax.ObjectConsItem{ + { + KeyExpr: &hclsyntax.LiteralValueExpr{Val: cty.StringVal("X-Status")}, + ValueExpr: &hclsyntax.LiteralValueExpr{Val: cty.NumberIntVal(int64(tt.reflectStatus))}, + }, + }, + }}, + }} + if tt.attr != nil { + content.Attributes[tt.attr.Name] = tt.attr + } + + producers := []producer.Roundtrip{ + &producer.Proxy{ + Content: content, + Name: "proxy", + RoundTrip: handler.NewProxy(backend, content, false, logEntry), + }, + } + testNames := []string{"request", "proxy"} + + for i, rt := range producers { + t.Run(testNames[i]+"_"+tt.name, func(t *testing.T) { + + ctx := eval.NewDefaultContext().WithClientRequest(clientRequest) + + outreq := clientRequest.WithContext(ctx) + outreq.Header.Set("X-Status", strconv.Itoa(tt.reflectStatus)) + + result := rt.Produce(outreq) + + if !errors.Equals(tt.expectedErr, result.Err) { + t.Fatalf("expected error: %v, got %v", tt.expectedErr, result.Err) + } + + if result.Beresp == nil { + t.Fatal("expected a backend response") + } + }) + } + } +} diff --git a/handler/producer/result.go b/handler/producer/result.go index 5f11cc923..660ae18a3 100644 --- a/handler/producer/result.go +++ b/handler/producer/result.go @@ -45,6 +45,19 @@ func roundtrip(rt http.RoundTripper, req *http.Request) *Result { } } + if unexpStatus, ok := req.Context().Value(request.EndpointUnexpectedStatus).([]int64); beresp != nil && + ok && len(unexpStatus) > 0 { + for _, unexp := range unexpStatus { + if beresp.StatusCode == int(unexp) { + return &Result{ + Beresp: beresp, + Err: errors.UnexpectedStatus.With(err), + RoundTripName: rtn, + } + } + } + } + if expStatus, ok := req.Context().Value(request.EndpointExpectedStatus).([]int64); beresp != nil && ok && len(expStatus) > 0 { var seen bool diff --git a/handler/proxy.go b/handler/proxy.go index cace2196a..98e2ba7f5 100644 --- a/handler/proxy.go +++ b/handler/proxy.go @@ -76,6 +76,13 @@ func (p *Proxy) RoundTrip(req *http.Request) (*http.Response, error) { outCtx = context.WithValue(outCtx, request.EndpointExpectedStatus, seetie.ValueToIntSlice(expStatusVal)) + unexpStatusVal, err := eval.ValueFromBodyAttribute(hclCtx, p.context, "unexpected_status") + if err != nil { + return nil, err + } + + outCtx = context.WithValue(outCtx, request.EndpointUnexpectedStatus, seetie.ValueToIntSlice(unexpStatusVal)) + *req = *req.WithContext(outCtx) if err = p.registerWebsocketsResponse(req); err != nil { diff --git a/main_test.go b/main_test.go index 2016c7c7e..a1b01d485 100644 --- a/main_test.go +++ b/main_test.go @@ -54,6 +54,7 @@ func Test_realmain(t *testing.T) { {"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: referenced proxy \"foo\" is not defined; " build=dev`, 1}, {"circular backend references", []string{"couper", "run", "-f", base + "/21_couper.hcl"}, nil, `level=error msg="configuration error: : configuration error; circular reference:`, 1}, + {"expected and unexpected status in proxy", []string{"couper", "run", "-f", base + "/18_couper.hcl"}, nil, `level=error msg="%s/18_couper.hcl:4,13-10,8: only one of expected_status and unexpected_status is allowed in a proxy block; `, 1}, } for _, tt := range tests { t.Run(tt.name, func(subT *testing.T) { diff --git a/server/testdata/settings/18_couper.hcl b/server/testdata/settings/18_couper.hcl new file mode 100644 index 000000000..3daaf6f5a --- /dev/null +++ b/server/testdata/settings/18_couper.hcl @@ -0,0 +1,13 @@ +server { + api { + endpoint "/**" { + proxy { + backend { + origin = "https://example.com" + } + expected_status = [200] + unexpected_status = [401] + } + } + } +}