diff --git a/README.md b/README.md index 0593a88..10fe8e1 100644 --- a/README.md +++ b/README.md @@ -224,14 +224,14 @@ Manifest is loaded as **template**, so you can use variables, Go **range** and * "query_params_from_store": { "format": "formatFromDatastore", // If the datastore key starts with an ?, wo do not throw an error if the key could not be found, but just - // do not set the query param. If the key "a" is not found it datastore, the queryparameter test will not be set + // do not set the query param. If the key "a" is not found it datastore, the query parameter test will not be set "test": "?a" }, // Additional headers that should be added to the request "header": { "header1": "value", - "header2": "value" + "header2": ["value1", "value2"] }, // Cookies can be added to the request @@ -270,7 +270,7 @@ Manifest is loaded as **template**, so you can use variables, Go **range** and * } ], - // With header_from_you set a header to the value of the dat astore field + // With header_from_store you set a header to the value of the datastore field // In this example we set the "Content-Type" header to the value "application/json" // As "application/json" is stored as string in the datastore on index "contentType" "header_from_store": { @@ -298,13 +298,22 @@ Manifest is loaded as **template**, so you can use variables, Go **range** and * // Expected http status code. See api documentation vor the right ones "statuscode": 200, - // If you expect certain response headers, you can define them here. A single key can have mulitble headers (as defined in rfc2616) + // If you expect certain response headers, you can define them here. A single key can have multiple headers (as defined in rfc2616) "header": { "key1": [ "val1", "val2", "val3" ], + + // Headers sharing the same key are concatenated using ";", if the comparison value is a simple string, + // thus "key1" can also be checked like this: + "key1": "val1;val2;val3" + + // :control in header is always applied to the flat format + "key1:control": { + // see below, this is not applied against the array + }, "x-easydb-token": [ "csdklmwerf8ßwji02kopwfjko2" ] @@ -1931,10 +1940,7 @@ The datastore stores all responses in a list. We can retrieve the response (as a { "statuscode": 200, "header": { - "foo": [ - "bar", - "baz" - ] + "foo": "bar;baz" }, "body": "..." } @@ -2658,7 +2664,7 @@ The endpoint `bounce` returns the binary of the request body, as well as the req "param1": "abc" }, "header": { - "header1": 123 + "header1": "123" }, "body": { "file": "@path/to/file.jpg" diff --git a/api_testcase.go b/api_testcase.go index 42bdd6c..ea032a8 100644 --- a/api_testcase.go +++ b/api_testcase.go @@ -49,8 +49,7 @@ type Case struct { index int dataStore *datastore.Datastore - standardHeader map[string]*string - headerFlat map[string]*string + standardHeader map[string]any // can be string or []string standardHeaderFromStore map[string]string ServerURL string `json:"server_url"` @@ -490,7 +489,7 @@ func (testCase Case) responsesEqual(expected, got api.Response) (compare.Compare if err != nil { return compare.CompareResult{}, fmt.Errorf("error loading expected generic json: %s", err) } - if len(expected.Body) == 0 { + if len(expected.Body) == 0 && len(expected.BodyControl) == 0 { expected.Format.IgnoreBody = true } else { expected.Format.IgnoreBody = false @@ -524,10 +523,10 @@ func (testCase Case) loadRequestSerialization() (api.Request, error) { spec.ServerURL = testCase.ServerURL } if len(spec.Headers) == 0 { - spec.Headers = make(map[string]*string) + spec.Headers = make(map[string]any) } for k, v := range testCase.standardHeader { - if spec.Headers[k] == nil { + if _, exist := spec.Headers[k]; !exist { spec.Headers[k] = v } } @@ -536,7 +535,7 @@ func (testCase Case) loadRequestSerialization() (api.Request, error) { spec.HeaderFromStore = make(map[string]string) } for k, v := range testCase.standardHeaderFromStore { - if spec.HeaderFromStore[k] == "" { + if _, exist := spec.HeaderFromStore[k]; !exist { spec.HeaderFromStore[k] = v } } diff --git a/api_testsuite.go b/api_testsuite.go index 8c6ebd3..bd3ad3a 100644 --- a/api_testsuite.go +++ b/api_testsuite.go @@ -42,9 +42,8 @@ type Suite struct { Tests []any `json:"tests"` Store map[string]any `json:"store"` - StandardHeader map[string]*string `yaml:"header" json:"header"` - HeaderFlat map[string]*string `yaml:"header_flat" json:"header_flat"` - StandardHeaderFromStore map[string]string `yaml:"header_from_store" json:"header_from_store"` + StandardHeader map[string]any `yaml:"header" json:"header"` + StandardHeaderFromStore map[string]string `yaml:"header_from_store" json:"header_from_store"` Config TestToolConfig datastore *datastore.Datastore @@ -399,7 +398,6 @@ func (ats *Suite) runLiteralTest( test.index = index test.dataStore = ats.datastore test.standardHeader = ats.StandardHeader - test.headerFlat = ats.HeaderFlat test.standardHeaderFromStore = ats.StandardHeaderFromStore if test.LogNetwork == nil { test.LogNetwork = &ats.Config.LogNetwork diff --git a/pkg/lib/api/request.go b/pkg/lib/api/request.go index 0ef7938..5d48489 100755 --- a/pkg/lib/api/request.go +++ b/pkg/lib/api/request.go @@ -46,7 +46,7 @@ type Request struct { NoRedirect bool `yaml:"no_redirect" json:"no_redirect"` QueryParams map[string]any `yaml:"query_params" json:"query_params"` QueryParamsFromStore map[string]string `yaml:"query_params_from_store" json:"query_params_from_store"` - Headers map[string]*string `yaml:"header" json:"header"` + Headers map[string]any `yaml:"header" json:"header"` HeaderFromStore map[string]string `yaml:"header_from_store" json:"header_from_store"` Cookies map[string]*RequestCookie `yaml:"cookies" json:"cookies"` SetCookies []*Cookie `yaml:"header-x-test-set-cookie" json:"header-x-test-set-cookie"` @@ -202,12 +202,25 @@ func (request Request) buildHttpRequest() (req *http.Request, err error) { } for key, val := range request.Headers { - if *val == "" { - //Unset header explicit - req.Header.Del(key) - } else { - //ADD header - req.Header.Set(key, *val) + switch v := val.(type) { + case string: + if v == "" { + req.Header.Del(key) + } else { + req.Header.Set(key, v) + } + case []any: + vArr := []string{} + for _, vOne := range v { + vOneS, isString := vOne.(string) + if !isString { + return nil, fmt.Errorf("unsupported header %q value %T: %v", key, vOne, vOne) + } + vArr = append(vArr, vOneS) + } + req.Header[key] = vArr + default: + return nil, fmt.Errorf("unsupported header value %T: %v", val, val) } } @@ -344,9 +357,9 @@ func (request Request) Send() (response Response, err error) { if err != nil { return response, err } - response, err = NewResponse(httpResponse.StatusCode, header, nil, httpResponse.Cookies(), httpResponse.Body, ResponseFormat{}) + response, err = NewResponse(httpResponse.StatusCode, header, httpResponse.Cookies(), httpResponse.Body, nil, ResponseFormat{}) if err != nil { - return response, fmt.Errorf("error constructing response from http response") + return response, fmt.Errorf("error constructing response from http response: %w", err) } response.ReqDur = elapsedTime return response, err diff --git a/pkg/lib/api/response.go b/pkg/lib/api/response.go index 1aa79f1..5ea3966 100755 --- a/pkg/lib/api/response.go +++ b/pkg/lib/api/response.go @@ -18,32 +18,18 @@ import ( ) type Response struct { - StatusCode int - Headers map[string]any - HeaderFlat map[string]any - Cookies []*http.Cookie - Body []byte - Format ResponseFormat + StatusCode int + Headers map[string]any + HeaderFlat map[string]any // ":control" is an object, so we must use "any" here + Cookies []*http.Cookie + Body []byte + BodyControl util.JsonObject + Format ResponseFormat ReqDur time.Duration BodyLoadDur time.Duration } -func (res Response) SerializeHeaderFlat() (headers map[string]any, err error) { - headers = map[string]any{} - for k, h := range res.Headers { - switch v := h.(type) { - case string: - headers[k] = h - case nil: - headers[k] = "" - case []string: - headers[k] = strings.Join(v, "; ") - } - } - return headers, nil -} - func HttpHeaderToMap(header http.Header) (headers map[string]any, err error) { headers = map[string]any{} for k, h := range header { @@ -66,12 +52,17 @@ type Cookie struct { } type ResponseSerialization struct { - StatusCode int `yaml:"statuscode" json:"statuscode"` - Headers map[string]any `yaml:"header" json:"header,omitempty"` - HeaderFlat map[string]any `yaml:"header_flat" json:"header_flat,omitempty"` - Cookies map[string]Cookie `yaml:"cookie" json:"cookie,omitempty"` - Body any `yaml:"body" json:"body,omitempty"` - Format ResponseFormat `yaml:"format" json:"format,omitempty"` + StatusCode int `yaml:"statuscode" json:"statuscode"` + Headers map[string]any `yaml:"header" json:"header,omitempty"` + Cookies map[string]Cookie `yaml:"cookie" json:"cookie,omitempty"` + Body any `yaml:"body" json:"body,omitempty"` + BodyControl util.JsonObject `yaml:"body:control" json:"body:control,omitempty"` + Format ResponseFormat `yaml:"format" json:"format,omitempty"` +} + +type responseSerializationInternal struct { + ResponseSerialization + HeaderFlat map[string]any `json:"header_flat,omitemty"` } type ResponseFormat struct { @@ -84,18 +75,55 @@ type ResponseFormat struct { } func NewResponse(statusCode int, - headers map[string]any, - headerFlat map[string]any, + headersAny map[string]any, cookies []*http.Cookie, body io.Reader, + bodyControl util.JsonObject, bodyFormat ResponseFormat, ) (res Response, err error) { + + headerFlat := map[string]any{} + headers := map[string]any{} + + // parse headers and set HeaderFlat if the values are string + for key, value := range headersAny { + switch v := value.(type) { + case string: + headerFlat[key] = v + continue + case []any: + headerS := []string{} + for _, item := range v { + switch v2 := item.(type) { + case string: + headerS = append(headerS, v2) + continue + default: + return res, fmt.Errorf("unknown type %T in header %q", v2, key) + } + } + headers[key] = v + continue + case []string: + headers[key] = v + continue + case map[string]any: // check if that is a control + if strings.HasSuffix(key, ":control") { + headerFlat[key] = v + continue + } + } + // all valid cases continue above + return res, fmt.Errorf("unknown type %T in header %q", value, key) + } + res = Response{ - StatusCode: statusCode, - Headers: headers, - HeaderFlat: headerFlat, - Cookies: cookies, - Format: bodyFormat, + StatusCode: statusCode, + Headers: headers, + BodyControl: bodyControl, + HeaderFlat: headerFlat, + Cookies: cookies, + Format: bodyFormat, } if body != nil { start := time.Now() @@ -141,7 +169,7 @@ func NewResponseFromSpec(spec ResponseSerialization) (res Response, err error) { } } - return NewResponse(spec.StatusCode, spec.Headers, spec.HeaderFlat, cookies, body, spec.Format) + return NewResponse(spec.StatusCode, spec.Headers, cookies, body, spec.BodyControl, spec.Format) } // ServerResponseToGenericJSON parse response from server. convert xml, csv, binary to json if necessary @@ -211,15 +239,19 @@ func (response Response) ServerResponseToGenericJSON(responseFormat ResponseForm return res, fmt.Errorf("Invalid response format '%s'", responseFormat.Type) } - headerFlat, err := resp.SerializeHeaderFlat() - if err != nil { - return res, err - } - responseJSON := ResponseSerialization{ - StatusCode: resp.StatusCode, - Headers: resp.Headers, - HeaderFlat: headerFlat, + headerFlat := map[string]any{} + headersAny := map[string]any{} + for key, value := range resp.Headers { + headersAny[key] = value + values := value.([]string) // this must be []string, if not this panics + headerFlat[key] = strings.Join(values, ";") } + + responseJSON := responseSerializationInternal{} + responseJSON.StatusCode = resp.StatusCode + responseJSON.Headers = headersAny + responseJSON.HeaderFlat = headerFlat + // Build cookies map from standard bag if len(resp.Cookies) > 0 { responseJSON.Cookies = make(map[string]Cookie) @@ -282,11 +314,12 @@ func (response Response) ToGenericJSON() (any, error) { } } - responseJSON := ResponseSerialization{ - StatusCode: response.StatusCode, - Headers: response.Headers, - HeaderFlat: response.HeaderFlat, - } + responseJSON := responseSerializationInternal{} + + responseJSON.StatusCode = response.StatusCode + responseJSON.Headers = response.Headers + responseJSON.HeaderFlat = response.HeaderFlat + responseJSON.BodyControl = response.BodyControl // Build cookies map from standard bag if len(response.Cookies) > 0 { diff --git a/pkg/lib/api/response_test.go b/pkg/lib/api/response_test.go index 8767e67..09ed96a 100644 --- a/pkg/lib/api/response_test.go +++ b/pkg/lib/api/response_test.go @@ -73,7 +73,7 @@ func TestResponse_NewResponseFromSpec_StatusCode_not_set(t *testing.T) { } func TestResponse_NewResponse(t *testing.T) { - response, err := NewResponse(200, nil, nil, nil, strings.NewReader("foo"), ResponseFormat{}) + response, err := NewResponse(200, nil, nil, strings.NewReader("foo"), nil, ResponseFormat{}) go_test_utils.ExpectNoError(t, err, "unexpected error") go_test_utils.AssertIntEquals(t, response.StatusCode, 200) } @@ -86,7 +86,7 @@ func TestResponse_String(t *testing.T) { } }` - response, err := NewResponse(200, nil, nil, nil, strings.NewReader(requestString), ResponseFormat{}) + response, err := NewResponse(200, nil, nil, strings.NewReader(requestString), nil, ResponseFormat{}) go_test_utils.ExpectNoError(t, err, "error constructing response") assertString := "200\n\n\n" + requestString @@ -119,7 +119,7 @@ func TestResponse_Cookies(t *testing.T) { if err != nil { t.Fatal(err) } - response, err := NewResponse(res.StatusCode, header, nil, res.Cookies(), res.Body, ResponseFormat{}) + response, err := NewResponse(res.StatusCode, header, res.Cookies(), res.Body, nil, ResponseFormat{}) if err != nil { t.Fatal(err) } diff --git a/pkg/lib/template/template_loader_test.go b/pkg/lib/template/template_loader_test.go index 485cb9b..c1edfb0 100644 --- a/pkg/lib/template/template_loader_test.go +++ b/pkg/lib/template/template_loader_test.go @@ -82,7 +82,7 @@ func TestBigIntRender(t *testing.T) { inputNumber := "132132132182323" - resp, _ := api.NewResponse(200, nil, nil, nil, strings.NewReader(fmt.Sprintf(`{"bigINT":%s}`, inputNumber)), api.ResponseFormat{}) + resp, _ := api.NewResponse(200, nil, nil, strings.NewReader(fmt.Sprintf(`{"bigINT":%s}`, inputNumber)), nil, api.ResponseFormat{}) respJson, _ := resp.ServerResponseToJsonString(false) store.SetWithQjson(respJson, map[string]string{"testINT": "body.bigINT"}) @@ -408,13 +408,13 @@ func Test_DataStore_QJson(t *testing.T) { 200, map[string]any{"x-header": []string{"foo", "bar"}}, nil, - nil, strings.NewReader(`{ "flib": [ "flab", "flob" ] }`), + nil, api.ResponseFormat{}, ) store := datastore.NewStore(false) diff --git a/test/datastore/check.json b/test/datastore/check.json index 4cca479..ee07cfa 100644 --- a/test/datastore/check.json +++ b/test/datastore/check.json @@ -5,6 +5,9 @@ "server_url": "http://localhost:9999", "endpoint": "bounce-json", "method": "POST", + "header": { + "x-henk": "denk" + }, "body": [ {{ datastore -3 | qjson "body" }}, {{ datastore -2 | qjson "body" }}, @@ -13,12 +16,19 @@ }, "response": { "statuscode": 200, + "header": { + "Content-Length": "367" + }, "body": { "body": [ {"some": "data"}, {"some": ["more", "data"]}, {"some": "data"} - ] + ], + "body:control": { + "order_matters": true, + "no_extra": true + } } } } diff --git a/test/response/header/header1.json b/test/response/header/header1.json new file mode 100644 index 0000000..979ab86 --- /dev/null +++ b/test/response/header/header1.json @@ -0,0 +1,54 @@ +{ + "name": "test header", + "request": { + "server_url": "http://localhost:9999", + "endpoint": "bounce-json", + "method": "POST", + "header": { + "x-key": "value", + "x-key-array": [ + "value1", + "value2" + ] + }, + "body": { + "test": "torsten" + } + }, + "response": { + "statuscode": 200, + "header": { + "Content-Type": "text/plain; charset=utf-8" + }, + "body": { + "header:control": { + "no_extra": true + }, + "header": { + "Connection": [ + "close" + ], + "Content-Length": [ + "18" + ], + "Content-Type": [ + "application/json" + ], + "X-Key": [ + "value" + ], + "X-Key-Array": [ + "value1", + "value2" + ] + }, + "query_params": {}, + "body:control": { + "no_extra": true + }, + "body": { + "test": "torsten" + } + } + } +} \ No newline at end of file diff --git a/test/response/header/header2.json b/test/response/header/header2.json new file mode 100644 index 0000000..898f198 --- /dev/null +++ b/test/response/header/header2.json @@ -0,0 +1,16 @@ +{ + "name": "test response header (check for a failing test with a reverse result)", + "reverse_test_result": true, + "request": { + "server_url": "http://localhost:9999", + "endpoint": "bounce-json", + "method": "POST" + }, + "response": { + "statuscode": 200, + "header": { + "Content-Length": "foo", + "Content-Type": "bar" + } + } +} \ No newline at end of file diff --git a/test/response/header/header3.json b/test/response/header/header3.json new file mode 100644 index 0000000..5d68ccf --- /dev/null +++ b/test/response/header/header3.json @@ -0,0 +1,19 @@ +{ + "name": "test response header:control for header values", + "request": { + "server_url": "http://localhost:9999", + "endpoint": "bounce-json", + "method": "POST" + }, + "response": { + "statuscode": 200, + "header": { + "Content-Length:control": { + "match": "^\\d+$" + }, + "Content-Type:control": { + "match": "^text/plain;.*" + } + } + } +} \ No newline at end of file diff --git a/test/response/header/header4.json b/test/response/header/header4.json new file mode 100644 index 0000000..de16104 --- /dev/null +++ b/test/response/header/header4.json @@ -0,0 +1,20 @@ +{ + "name": "test response header:control for header values (check for a failing test with a reverse result)", + "reverse_test_result": true, + "request": { + "server_url": "http://localhost:9999", + "endpoint": "bounce-json", + "method": "POST" + }, + "response": { + "statuscode": 200, + "header": { + "Content-Length:control": { + "match": "foo" + }, + "Content-Type:control": { + "match": "bar" + } + } + } +} \ No newline at end of file diff --git a/test/response/header/header5.json b/test/response/header/header5.json new file mode 100644 index 0000000..d5143b5 --- /dev/null +++ b/test/response/header/header5.json @@ -0,0 +1,16 @@ +{ + "name": "test response header", + "request": { + "server_url": "http://localhost:9999", + "endpoint": "bounce-json", + "method": "POST" + }, + "response": { + "statuscode": 200, + "header": { + "Content-Type": [ + "text/plain; charset=utf-8" + ] + } + } +} \ No newline at end of file diff --git a/test/response/header/header6.json b/test/response/header/header6.json new file mode 100644 index 0000000..9886835 --- /dev/null +++ b/test/response/header/header6.json @@ -0,0 +1,43 @@ +{ + "name": "check HTTP header using control, use reverse_test_result", + "request": { + "server_url": "http://localhost:9999", + "endpoint": "bounce-json", + "method": "POST", + "body": [ + "henk 1", + "henk 2", + "henk 3" + ] + }, + "response": { + "statuscode": 200, + "header": { + // test the string format + "Content-Length": "237", + "Content-Type": "text/plain; charset=utf-8" + }, + "body:control": { + "no_extra": true + }, + "body": { + "header": { + "Connection": [ + "close" + ], + "Content-Length": [ + "28" + ], + "Content-Type": [ + "application/json" + ] + }, + "query_params": {}, + "body": [ + "henk 1", + "henk 2", + "henk 3" + ] + } + } +} \ No newline at end of file diff --git a/test/response/header/manifest.json b/test/response/header/manifest.json index 4771746..dc4b15e 100644 --- a/test/response/header/manifest.json +++ b/test/response/header/manifest.json @@ -4,81 +4,13 @@ "dir": "../_res", "testmode": false }, - "name": "response header: format header_flat", + "name": "response header: format header", "tests": [ - { - "name": "test response header_flat", - "request": { - "server_url": "http://localhost:9999", - "endpoint": "bounce-json", - "method": "POST" - }, - "response": { - "statuscode": 200, - "header": { - "Content-Type": [ - "text/plain; charset=utf-8" - ] - }, - "header_flat": { - "Content-Type": "text/plain; charset=utf-8" - } - } - }, - { - "name": "test response header_flat (check for a failing test with a reverse result)", - "reverse_test_result": true, - "request": { - "server_url": "http://localhost:9999", - "endpoint": "bounce-json", - "method": "POST" - }, - "response": { - "statuscode": 200, - "header_flat": { - "Content-Length": "foo", - "Content-Type": "bar" - } - } - }, - { - "name": "test response header_flat: control for header values", - "request": { - "server_url": "http://localhost:9999", - "endpoint": "bounce-json", - "method": "POST" - }, - "response": { - "statuscode": 200, - "header_flat": { - "Content-Length:control": { - "match": "^\\d+$" - }, - "Content-Type:control": { - "match": "^text/plain;.*" - } - } - } - }, - { - "name": "test response header_flat: control for header values (check for a failing test with a reverse result)", - "reverse_test_result": true, - "request": { - "server_url": "http://localhost:9999", - "endpoint": "bounce-json", - "method": "POST" - }, - "response": { - "statuscode": 200, - "header_flat": { - "Content-Length:control": { - "match": "foo" - }, - "Content-Type:control": { - "match": "bar" - } - } - } - } + "@header1.json" + ,"@header2.json" + ,"@header3.json" + ,"@header4.json" + ,"@header5.json" + ,"@header6.json" ] } \ No newline at end of file