diff --git a/js/modules/k6/http/http.go b/js/modules/k6/http/http.go index 9aca5f2d82e..24bc72e9f9b 100644 --- a/js/modules/k6/http/http.go +++ b/js/modules/k6/http/http.go @@ -26,6 +26,7 @@ import ( "go.k6.io/k6/js/common" "go.k6.io/k6/lib" "go.k6.io/k6/lib/netext" + "go.k6.io/k6/lib/netext/httpext" ) const ( @@ -99,10 +100,12 @@ type HTTP struct { responseCallback func(int) bool } +// XCookieJar creates a new cookie jar object. func (*HTTP) XCookieJar(ctx *context.Context) *HTTPCookieJar { return newCookieJar(ctx) } +// CookieJar returns the active cookie jar for the current VU. func (*HTTP) CookieJar(ctx context.Context) (*HTTPCookieJar, error) { state := lib.GetState(ctx) if state == nil { @@ -110,3 +113,17 @@ func (*HTTP) CookieJar(ctx context.Context) (*HTTPCookieJar, error) { } return &HTTPCookieJar{state.CookieJar, &ctx}, nil } + +// URL creates a new URL from the provided parts +func (*HTTP) URL(parts []string, pieces ...string) (httpext.URL, error) { + var name, urlstr string + for i, part := range parts { + name += part + urlstr += part + if i < len(pieces) { + name += "${}" + urlstr += pieces[i] + } + } + return httpext.NewURL(urlstr, name) +} diff --git a/js/modules/k6/http/http_url.go b/js/modules/k6/http/http_url.go deleted file mode 100644 index 7189a929c80..00000000000 --- a/js/modules/k6/http/http_url.go +++ /dev/null @@ -1,60 +0,0 @@ -/* - * - * k6 - a next-generation load testing tool - * Copyright (C) 2016 Load Impact - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ - -package http - -import ( - "fmt" - - "github.com/dop251/goja" - - "go.k6.io/k6/lib/netext/httpext" -) - -// ToURL tries to convert anything passed to it to a k6 URL struct -func ToURL(u interface{}) (httpext.URL, error) { - switch tu := u.(type) { - case httpext.URL: - // Handling of http.url`http://example.com/{$id}` - return tu, nil - case string: - // Handling of "http://example.com/" - return httpext.NewURL(tu, tu) - case goja.Value: - // Unwrap goja values - return ToURL(tu.Export()) - default: - return httpext.URL{}, fmt.Errorf("invalid URL value '%#v'", u) - } -} - -// URL creates new URL from the provided parts -func (http *HTTP) URL(parts []string, pieces ...string) (httpext.URL, error) { - var name, urlstr string - for i, part := range parts { - name += part - urlstr += part - if i < len(pieces) { - name += "${}" - urlstr += pieces[i] - } - } - return httpext.NewURL(urlstr, name) -} diff --git a/js/modules/k6/http/request.go b/js/modules/k6/http/request.go index a19b0611980..44644022081 100644 --- a/js/modules/k6/http/request.go +++ b/js/modules/k6/http/request.go @@ -23,6 +23,7 @@ package http import ( "bytes" "context" + "errors" "fmt" "mime/multipart" "net/http" @@ -89,9 +90,9 @@ func (h *HTTP) Options(ctx context.Context, url goja.Value, args ...goja.Value) // Request makes an http request of the provided `method` and returns a corresponding response by // taking goja.Values as arguments func (h *HTTP) Request(ctx context.Context, method string, url goja.Value, args ...goja.Value) (*Response, error) { - u, err := ToURL(url) - if err != nil { - return nil, err + state := lib.GetState(ctx) + if state == nil { + return nil, ErrHTTPForbiddenInInitContext } var body interface{} @@ -104,9 +105,19 @@ func (h *HTTP) Request(ctx context.Context, method string, url goja.Value, args params = args[1] } - req, err := h.parseRequest(ctx, method, u, body, params) + req, err := h.parseRequest(ctx, method, url, body, params) if err != nil { - return nil, err + if state.Options.Throw.Bool { + return nil, err + } + state.Logger.WithField("error", err).Warn("Request Failed") + r := httpext.NewResponse(ctx) + r.Error = err.Error() + var k6e httpext.K6Error + if errors.As(err, &k6e) { + r.ErrorCode = int(k6e.Code) + } + return &Response{Response: r}, nil } resp, err := httpext.MakeRequest(ctx, req) @@ -120,7 +131,7 @@ func (h *HTTP) Request(ctx context.Context, method string, url goja.Value, args //TODO break this function up //nolint: gocyclo func (h *HTTP) parseRequest( - ctx context.Context, method string, reqURL httpext.URL, body interface{}, params goja.Value, + ctx context.Context, method string, reqURL, body interface{}, params goja.Value, ) (*httpext.ParsedHTTPRequest, error) { rt := common.GetRuntime(ctx) state := lib.GetState(ctx) @@ -128,11 +139,19 @@ func (h *HTTP) parseRequest( return nil, ErrHTTPForbiddenInInitContext } + if urlJSValue, ok := reqURL.(goja.Value); ok { + reqURL = urlJSValue.Export() + } + u, err := httpext.ToURL(reqURL) + if err != nil { + return nil, err + } + result := &httpext.ParsedHTTPRequest{ - URL: &reqURL, + URL: &u, Req: &http.Request{ Method: method, - URL: reqURL.GetURL(), + URL: u.GetURL(), Header: make(http.Header), }, Timeout: 60 * time.Second, @@ -380,16 +399,22 @@ func (h *HTTP) prepareBatchArray( results := make([]*Response, reqCount) for i, req := range requests { + resp := httpext.NewResponse(ctx) parsedReq, err := h.parseBatchRequest(ctx, i, req) if err != nil { - return nil, nil, err + resp.Error = err.Error() + var k6e httpext.K6Error + if errors.As(err, &k6e) { + resp.ErrorCode = int(k6e.Code) + } + results[i] = h.responseFromHttpext(resp) + return batchReqs, results, err } - response := new(httpext.Response) batchReqs[i] = httpext.BatchParsedHTTPRequest{ ParsedHTTPRequest: parsedReq, - Response: response, + Response: resp, } - results[i] = h.responseFromHttpext(response) + results[i] = h.responseFromHttpext(resp) } return batchReqs, results, nil @@ -404,16 +429,22 @@ func (h *HTTP) prepareBatchObject( i := 0 for key, req := range requests { + resp := httpext.NewResponse(ctx) parsedReq, err := h.parseBatchRequest(ctx, key, req) if err != nil { - return nil, nil, err + resp.Error = err.Error() + var k6e httpext.K6Error + if errors.As(err, &k6e) { + resp.ErrorCode = int(k6e.Code) + } + results[key] = h.responseFromHttpext(resp) + return batchReqs, results, err } - response := new(httpext.Response) batchReqs[i] = httpext.BatchParsedHTTPRequest{ ParsedHTTPRequest: parsedReq, - Response: response, + Response: resp, } - results[key] = h.responseFromHttpext(response) + results[key] = h.responseFromHttpext(resp) i++ } @@ -422,7 +453,7 @@ func (h *HTTP) prepareBatchObject( // Batch makes multiple simultaneous HTTP requests. The provideds reqsV should be an array of request // objects. Batch returns an array of responses and/or error -func (h *HTTP) Batch(ctx context.Context, reqsV goja.Value) (goja.Value, error) { +func (h *HTTP) Batch(ctx context.Context, reqsV goja.Value) (interface{}, error) { state := lib.GetState(ctx) if state == nil { return nil, ErrBatchForbiddenInInitContext @@ -444,7 +475,11 @@ func (h *HTTP) Batch(ctx context.Context, reqsV goja.Value) (goja.Value, error) } if err != nil { - return nil, err + if state.Options.Throw.Bool { + return nil, err + } + state.Logger.WithField("error", err).Warn("A batch request failed") + return results, nil } reqCount := len(batchReqs) @@ -459,20 +494,18 @@ func (h *HTTP) Batch(ctx context.Context, reqsV goja.Value) (goja.Value, error) err = e } } - return common.GetRuntime(ctx).ToValue(results), err + return results, err } func (h *HTTP) parseBatchRequest( ctx context.Context, key interface{}, val interface{}, ) (*httpext.ParsedHTTPRequest, error) { var ( - method = HTTP_METHOD_GET - ok bool - err error - reqURL httpext.URL - body interface{} - params goja.Value - rt = common.GetRuntime(ctx) + method = HTTP_METHOD_GET + ok bool + body, reqURL interface{} + params goja.Value + rt = common.GetRuntime(ctx) ) switch data := val.(type) { @@ -486,10 +519,7 @@ func (h *HTTP) parseBatchRequest( if !ok { return nil, fmt.Errorf("invalid method type '%#v'", data[0]) } - reqURL, err = ToURL(data[1]) - if err != nil { - return nil, err - } + reqURL = data[1] if dataLen > 2 { body = data[2] } @@ -499,12 +529,11 @@ func (h *HTTP) parseBatchRequest( case map[string]interface{}: // Handling of {method: "GET", url: "https://test.k6.io"} - if murl, ok := data["url"]; !ok { - return nil, fmt.Errorf("batch request %q doesn't have an url key", key) - } else if reqURL, err = ToURL(murl); err != nil { - return nil, err + if _, ok := data["url"]; !ok { + return nil, fmt.Errorf("batch request %v doesn't have a url key", key) } + reqURL = data["url"] body = data["body"] // It's fine if it's missing, the map lookup will return if newMethod, ok := data["method"]; ok { @@ -520,13 +549,8 @@ func (h *HTTP) parseBatchRequest( if p, ok := data["params"]; ok { params = rt.ToValue(p) } - default: - // Handling of "http://example.com/" or http.url`http://example.com/{$id}` - reqURL, err = ToURL(data) - if err != nil { - return nil, err - } + reqURL = val } return h.parseRequest(ctx, method, reqURL, body, params) diff --git a/js/modules/k6/http/request_test.go b/js/modules/k6/http/request_test.go index 0d9d7a653d0..55c0806d37e 100644 --- a/js/modules/k6/http/request_test.go +++ b/js/modules/k6/http/request_test.go @@ -572,6 +572,79 @@ func TestRequestAndBatch(t *testing.T) { } }) }) + t.Run("InvalidURL", func(t *testing.T) { + t.Parallel() + + expErr := `invalid URL: parse "https:// test.k6.io": invalid character " " in host name` + t.Run("throw=true", func(t *testing.T) { + js := ` + http.request("GET", "https:// test.k6.io"); + throw new Error("whoops!"); // shouldn't be reached + ` + _, err := rt.RunString(js) + require.Error(t, err) + assert.Contains(t, err.Error(), expErr) + }) + + t.Run("throw=false", func(t *testing.T) { + state.Options.Throw.Bool = false + defer func() { state.Options.Throw.Bool = true }() + + hook := logtest.NewLocal(state.Logger) + defer hook.Reset() + + js := ` + (function(){ + var r = http.request("GET", "https:// test.k6.io"); + return {error: r.error, error_code: r.error_code}; + })() + ` + ret, err := rt.RunString(js) + require.NoError(t, err) + require.NotNil(t, ret) + var retobj map[string]interface{} + var ok bool + if retobj, ok = ret.Export().(map[string]interface{}); !ok { + require.Fail(t, "got wrong return object: %#+v", retobj) + } + require.Equal(t, int64(1020), retobj["error_code"]) + require.Equal(t, expErr, retobj["error"]) + + logEntry := hook.LastEntry() + require.NotNil(t, logEntry) + assert.Equal(t, logrus.WarnLevel, logEntry.Level) + assert.Contains(t, logEntry.Data["error"].(error).Error(), expErr) + assert.Equal(t, "Request Failed", logEntry.Message) + }) + + t.Run("throw=false,nopanic", func(t *testing.T) { + state.Options.Throw.Bool = false + defer func() { state.Options.Throw.Bool = true }() + + hook := logtest.NewLocal(state.Logger) + defer hook.Reset() + + js := ` + (function(){ + var r = http.request("GET", "https:// test.k6.io"); + r.html(); + r.json(); + return r.error_code; // not reached because of json() + })() + ` + ret, err := rt.RunString(js) + require.Error(t, err) + assert.Nil(t, ret) + assert.Contains(t, err.Error(), "unexpected end of JSON input") + + logEntry := hook.LastEntry() + require.NotNil(t, logEntry) + assert.Equal(t, logrus.WarnLevel, logEntry.Level) + assert.Contains(t, logEntry.Data["error"].(error).Error(), expErr) + assert.Equal(t, "Request Failed", logEntry.Message) + }) + }) + t.Run("Unroutable", func(t *testing.T) { _, err := rt.RunString(`http.request("GET", "http://sdafsgdhfjg/");`) assert.Error(t, err) @@ -1178,8 +1251,132 @@ func TestRequestAndBatch(t *testing.T) { t.Run("Batch", func(t *testing.T) { t.Run("error", func(t *testing.T) { - _, err := rt.RunString(`var res = http.batch("https://somevalidurl.com");`) - require.Error(t, err) + invalidURLerr := `invalid URL: parse "https:// invalidurl.com": invalid character " " in host name` + testCases := []struct { + name, code, expErr string + throw bool + }{ + { + name: "invalid arg", code: `"https://somevalidurl.com"`, + expErr: `invalid http.batch() argument type string`, throw: true, + }, + { + name: "invalid URL short", code: `["https:// invalidurl.com"]`, + expErr: invalidURLerr, throw: true, + }, + { + name: "invalid URL short no throw", code: `["https:// invalidurl.com"]`, + expErr: invalidURLerr, throw: false, + }, + { + name: "invalid URL array", code: `[ ["GET", "https:// invalidurl.com"] ]`, + expErr: invalidURLerr, throw: true, + }, + { + name: "invalid URL array no throw", code: `[ ["GET", "https:// invalidurl.com"] ]`, + expErr: invalidURLerr, throw: false, + }, + { + name: "invalid URL object", code: `[ {method: "GET", url: "https:// invalidurl.com"} ]`, + expErr: invalidURLerr, throw: true, + }, + { + name: "invalid object no throw", code: `[ {method: "GET", url: "https:// invalidurl.com"} ]`, + expErr: invalidURLerr, throw: false, + }, + { + name: "object no url key", code: `[ {method: "GET"} ]`, + expErr: `batch request 0 doesn't have a url key`, throw: true, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { //nolint:paralleltest + oldThrow := state.Options.Throw.Bool + state.Options.Throw.Bool = tc.throw + defer func() { state.Options.Throw.Bool = oldThrow }() + + hook := logtest.NewLocal(state.Logger) + defer hook.Reset() + + ret, err := rt.RunString(fmt.Sprintf(` + (function(){ + var r = http.batch(%s); + if (r.length !== 1) throw new Error('unexpected responses length: '+r.length); + return {error: r[0].error, error_code: r[0].error_code}; + })()`, tc.code)) + if tc.throw { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expErr) + require.Nil(t, ret) + } else { + require.NoError(t, err) + require.NotNil(t, ret) + var retobj map[string]interface{} + var ok bool + if retobj, ok = ret.Export().(map[string]interface{}); !ok { + require.Fail(t, "got wrong return object: %#+v", retobj) + } + require.Equal(t, int64(1020), retobj["error_code"]) + require.Equal(t, invalidURLerr, retobj["error"]) + + logEntry := hook.LastEntry() + require.NotNil(t, logEntry) + assert.Equal(t, logrus.WarnLevel, logEntry.Level) + assert.Contains(t, logEntry.Data["error"].(error).Error(), tc.expErr) + assert.Equal(t, "A batch request failed", logEntry.Message) + } + }) + } + }) + t.Run("error,nopanic", func(t *testing.T) { //nolint:paralleltest + invalidURLerr := `invalid URL: parse "https:// invalidurl.com": invalid character " " in host name` + testCases := []struct{ name, code string }{ + { + name: "array", code: `[ + ["GET", "https:// invalidurl.com"], + ["GET", "https://somevalidurl.com"], + ]`, + }, + { + name: "object", code: `[ + {method: "GET", url: "https:// invalidurl.com"}, + {method: "GET", url: "https://somevalidurl.com"}, + ]`, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { //nolint:paralleltest + oldThrow := state.Options.Throw.Bool + state.Options.Throw.Bool = false + defer func() { state.Options.Throw.Bool = oldThrow }() + + hook := logtest.NewLocal(state.Logger) + defer hook.Reset() + + ret, err := rt.RunString(fmt.Sprintf(` + (function(){ + var r = http.batch(%s); + if (r.length !== 2) throw new Error('unexpected responses length: '+r.length); + if (r[1] !== null) throw new Error('expected response at index 1 to be null'); + r[0].html(); + r[0].json(); + return r[0].error_code; // not reached because of json() + })() + `, tc.code)) + require.Error(t, err) + assert.Nil(t, ret) + assert.Contains(t, err.Error(), "unexpected end of JSON input") + logEntry := hook.LastEntry() + require.NotNil(t, logEntry) + assert.Equal(t, logrus.WarnLevel, logEntry.Level) + assert.Contains(t, logEntry.Data["error"].(error).Error(), invalidURLerr) + assert.Equal(t, "A batch request failed", logEntry.Message) + }) + } }) t.Run("GET", func(t *testing.T) { _, err := rt.RunString(sr(` diff --git a/lib/netext/httpext/error_codes.go b/lib/netext/httpext/error_codes.go index d76bd4c6e69..a69dcdf5ae0 100644 --- a/lib/netext/httpext/error_codes.go +++ b/lib/netext/httpext/error_codes.go @@ -46,6 +46,7 @@ const ( // non specific defaultErrorCode errCode = 1000 defaultNetNonTCPErrorCode errCode = 1010 + invalidURLErrorCode errCode = 1020 requestTimeoutErrorCode errCode = 1050 // DNS errors defaultDNSErrorCode errCode = 1100 @@ -101,6 +102,7 @@ const ( x509HostnameErrorCodeMsg = "x509: certificate doesn't match hostname" x509UnknownAuthority = "x509: unknown authority" requestTimeoutErrorCodeMsg = "request timeout" + invalidURLErrorCodeMsg = "invalid URL" ) func http2ErrCodeOffset(code http2.ErrCode) errCode { diff --git a/lib/netext/httpext/request.go b/lib/netext/httpext/request.go index 3839d71e8d2..b48cd45a17b 100644 --- a/lib/netext/httpext/request.go +++ b/lib/netext/httpext/request.go @@ -30,7 +30,6 @@ import ( "net" "net/http" "net/http/cookiejar" - "net/url" "strconv" "strings" "time" @@ -49,50 +48,6 @@ type HTTPRequestCookie struct { Replace bool } -// A URL wraps net.URL, and preserves the template (if any) the URL was constructed from. -type URL struct { - u *url.URL - Name string // http://example.com/thing/${}/ - URL string // http://example.com/thing/1234/ - CleanURL string // URL with masked user credentials, used for output -} - -// NewURL returns a new URL for the provided url and name. The error is returned if the url provided -// can't be parsed -func NewURL(urlString, name string) (URL, error) { - u, err := url.Parse(urlString) - newURL := URL{u: u, Name: name, URL: urlString} - newURL.CleanURL = newURL.Clean() - if urlString == name { - newURL.Name = newURL.CleanURL - } - return newURL, err -} - -// Clean returns an output-safe representation of URL -func (u URL) Clean() string { - if u.CleanURL != "" { - return u.CleanURL - } - - if u.u == nil || u.u.User == nil { - return u.URL - } - - if password, passwordOk := u.u.User.Password(); passwordOk { - // here 3 is for the '://' and 4 is because of '://' and ':' between the credentials - return u.URL[:len(u.u.Scheme)+3] + "****:****" + u.URL[len(u.u.Scheme)+4+len(u.u.User.Username())+len(password):] - } - - // here 3 in both places is for the '://' - return u.URL[:len(u.u.Scheme)+3] + "****" + u.URL[len(u.u.Scheme)+3+len(u.u.User.Username()):] -} - -// GetURL returns the internal url.URL -func (u URL) GetURL() *url.URL { - return u.u -} - // Request represent an http request type Request struct { Method string `json:"method"` diff --git a/lib/netext/httpext/response.go b/lib/netext/httpext/response.go index 909cb7f235e..970c66c96c5 100644 --- a/lib/netext/httpext/response.go +++ b/lib/netext/httpext/response.go @@ -94,6 +94,14 @@ type Response struct { Request Request `json:"request"` } +// NewResponse returns an empty Response instance. +func NewResponse(ctx context.Context) *Response { + return &Response{ + ctx: ctx, + Body: []byte{}, + } +} + func (res *Response) setTLSInfo(tlsState *tls.ConnectionState) { tlsInfo, oscp := netext.ParseTLSConnState(tlsState) res.TLSVersion = tlsInfo.Version diff --git a/lib/netext/httpext/url.go b/lib/netext/httpext/url.go new file mode 100644 index 00000000000..d6d37d87167 --- /dev/null +++ b/lib/netext/httpext/url.go @@ -0,0 +1,89 @@ +/* + * + * k6 - a next-generation load testing tool + * Copyright (C) 2016 Load Impact + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +package httpext + +import ( + "fmt" + "net/url" +) + +// A URL wraps net.URL, and preserves the template (if any) the URL was constructed from. +type URL struct { + u *url.URL + Name string // http://example.com/thing/${}/ + URL string // http://example.com/thing/1234/ + CleanURL string // URL with masked user credentials, used for output +} + +// NewURL returns a new URL for the provided url and name. The error is returned if the url provided +// can't be parsed +func NewURL(urlString, name string) (URL, error) { + u, err := url.Parse(urlString) + if err != nil { + return URL{}, NewK6Error(invalidURLErrorCode, + fmt.Sprintf("%s: %s", invalidURLErrorCodeMsg, err), err) + } + newURL := URL{u: u, Name: name, URL: urlString} + newURL.CleanURL = newURL.Clean() + if urlString == name { + newURL.Name = newURL.CleanURL + } + return newURL, nil +} + +// Clean returns an output-safe representation of URL +func (u URL) Clean() string { + if u.CleanURL != "" { + return u.CleanURL + } + + if u.u == nil || u.u.User == nil { + return u.URL + } + + if password, passwordOk := u.u.User.Password(); passwordOk { + // here 3 is for the '://' and 4 is because of '://' and ':' between the credentials + return u.URL[:len(u.u.Scheme)+3] + "****:****" + u.URL[len(u.u.Scheme)+4+len(u.u.User.Username())+len(password):] + } + + // here 3 in both places is for the '://' + return u.URL[:len(u.u.Scheme)+3] + "****" + u.URL[len(u.u.Scheme)+3+len(u.u.User.Username()):] +} + +// GetURL returns the internal url.URL +func (u URL) GetURL() *url.URL { + return u.u +} + +// ToURL tries to convert anything passed to it to a k6 URL struct +func ToURL(u interface{}) (URL, error) { + switch tu := u.(type) { + case URL: + // Handling of http.url`http://example.com/{$id}` + return tu, nil + case string: + // Handling of "http://example.com/" + return NewURL(tu, tu) + default: + return URL{}, NewK6Error(invalidURLErrorCode, + fmt.Sprintf("%s: '#%v'", invalidURLErrorCodeMsg, u), nil) + } +}