From 2da666a4e6319917faf94257f74b53e159008fc0 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 28 Dec 2021 21:14:15 +0800 Subject: [PATCH 1/8] feat: support creating and calling custom functions with go plugin --- convert.go | 1 + docs/CHANGELOG.md | 6 ++- examples/plugin/debugtalk.go | 14 +++++ models.go | 1 + parser.go | 100 +++++++++++++++++++++-------------- parser_test.go | 51 +++++++++++++----- response.go | 12 +++-- runner.go | 100 +++++++++++++++++++++++++++++++---- runner_test.go | 57 ++++++++++++++++++++ 9 files changed, 274 insertions(+), 68 deletions(-) create mode 100644 examples/plugin/debugtalk.go diff --git a/convert.go b/convert.go index 7f1bc830..2bfd6d08 100644 --- a/convert.go +++ b/convert.go @@ -146,6 +146,7 @@ func (path *TestCasePath) ToTestCase() (*TestCase, error) { if err != nil { return nil, err } + tc.Config.Path = path.Path testcase, err := tc.ToTestCase() if err != nil { return nil, err diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 26a81198..9488bbd4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,10 @@ # Release History -## v0.3.0 (2021-12-22) +## v0.4.0 (2021-12-28) + +- feat: support creating and calling custom functions with `go plugin` + +## v0.3.0 (2021-12-24) - feat: implement `transaction` mechanism for load test - feat: continue running next step when failure occurs with `--continue-on-failure` flag, default to failfast diff --git a/examples/plugin/debugtalk.go b/examples/plugin/debugtalk.go new file mode 100644 index 00000000..ce98e20c --- /dev/null +++ b/examples/plugin/debugtalk.go @@ -0,0 +1,14 @@ +package main + +import ( + "fmt" + "log" +) + +func init() { + log.Println("plugin init function called") +} + +func Concatenate(a int, b string, c float64) string { + return fmt.Sprintf("%v_%v_%v", a, b, c) +} diff --git a/models.go b/models.go index 34924faf..9ee1fd89 100644 --- a/models.go +++ b/models.go @@ -20,6 +20,7 @@ type TConfig struct { Parameters map[string]interface{} `json:"parameters,omitempty" yaml:"parameters,omitempty"` Export []string `json:"export,omitempty" yaml:"export,omitempty"` Weight int `json:"weight,omitempty" yaml:"weight,omitempty"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` // testcase file path } // Request represents HTTP request data structure. diff --git a/parser.go b/parser.go index e7398cd7..b9041d0c 100644 --- a/parser.go +++ b/parser.go @@ -31,9 +31,9 @@ func buildURL(baseURL, stepURL string) string { return uStep.String() } -func parseHeaders(rawHeaders map[string]string, variablesMapping map[string]interface{}) (map[string]string, error) { +func parseHeaders(rawHeaders map[string]string, variablesMapping map[string]interface{}, pluginLoader *pluginLoader) (map[string]string, error) { parsedHeaders := make(map[string]string) - headers, err := parseData(rawHeaders, variablesMapping) + headers, err := parseData(rawHeaders, variablesMapping, pluginLoader) if err != nil { return rawHeaders, err } @@ -53,7 +53,7 @@ func convertString(raw interface{}) string { } } -func parseData(raw interface{}, variablesMapping map[string]interface{}) (interface{}, error) { +func parseData(raw interface{}, variablesMapping map[string]interface{}, pluginLoader *pluginLoader) (interface{}, error) { rawValue := reflect.ValueOf(raw) switch rawValue.Kind() { case reflect.String: @@ -64,11 +64,11 @@ func parseData(raw interface{}, variablesMapping map[string]interface{}) (interf // other string value := rawValue.String() value = strings.TrimSpace(value) - return parseString(value, variablesMapping) + return parseString(value, variablesMapping, pluginLoader) case reflect.Slice: parsedSlice := make([]interface{}, rawValue.Len()) for i := 0; i < rawValue.Len(); i++ { - parsedValue, err := parseData(rawValue.Index(i).Interface(), variablesMapping) + parsedValue, err := parseData(rawValue.Index(i).Interface(), variablesMapping, pluginLoader) if err != nil { return raw, err } @@ -78,12 +78,12 @@ func parseData(raw interface{}, variablesMapping map[string]interface{}) (interf case reflect.Map: // convert any map to map[string]interface{} parsedMap := make(map[string]interface{}) for _, k := range rawValue.MapKeys() { - parsedKey, err := parseString(k.String(), variablesMapping) + parsedKey, err := parseString(k.String(), variablesMapping, pluginLoader) if err != nil { return raw, err } v := rawValue.MapIndex(k) - parsedValue, err := parseData(v.Interface(), variablesMapping) + parsedValue, err := parseData(v.Interface(), variablesMapping, pluginLoader) if err != nil { return raw, err } @@ -121,7 +121,7 @@ var ( ) // parseString parse string with variables -func parseString(raw string, variablesMapping map[string]interface{}) (interface{}, error) { +func parseString(raw string, variablesMapping map[string]interface{}, pluginLoader *pluginLoader) (interface{}, error) { matchStartPosition := 0 parsedString := "" remainedString := raw @@ -160,16 +160,25 @@ func parseString(raw string, variablesMapping map[string]interface{}) (interface if err != nil { return raw, err } - parsedArgs, err := parseData(arguments, variablesMapping) + parsedArgs, err := parseData(arguments, variablesMapping, pluginLoader) if err != nil { return raw, err } - result, err := callFunc(funcName, parsedArgs.([]interface{})...) + fn, err := getMappingFunction(funcName, pluginLoader) if err != nil { return raw, err } + result, err := callFunc(fn, parsedArgs.([]interface{})...) + if err != nil { + log.Error().Str("funcName", funcName).Interface("arguments", arguments). + Err(err).Msg("call function failed") + return raw, err + } + log.Info().Str("funcName", funcName).Interface("arguments", arguments). + Interface("output", result).Msg("call function success") + if funcMatched[0] == raw { // raw_string is a function, e.g. "${add_one(3)}", return its eval value directly return result, nil @@ -247,30 +256,50 @@ func mergeVariables(variables, overriddenVariables map[string]interface{}) map[s return mergedVariables } +func getMappingFunction(funcName string, pluginLoader *pluginLoader) (reflect.Value, error) { + var fn reflect.Value + var err error + + defer func() { + // check function type + if err == nil && fn.Kind() != reflect.Func { + // function not valid + err = fmt.Errorf("function %s is invalid", funcName) + return + } + }() + + // get function from plugin loader + if pluginLoader != nil { + sym, err := pluginLoader.Lookup(funcName) + if err == nil { + fn = reflect.ValueOf(sym) + return fn, nil + } + } + + // get builtin function + if function, ok := builtin.Functions[funcName]; ok { + fn = reflect.ValueOf(function) + return fn, nil + } + + // function not found + return reflect.Value{}, fmt.Errorf("function %s is not found", funcName) +} + // callFunc call function with arguments // only support return at most one result value -func callFunc(funcName string, arguments ...interface{}) (interface{}, error) { - function, ok := builtin.Functions[funcName] - if !ok { - // function not found - return nil, fmt.Errorf("function %s is not found", funcName) - } - - funcValue := reflect.ValueOf(function) - if funcValue.Kind() != reflect.Func { - // function not valid - return nil, fmt.Errorf("function %s is invalid", funcName) - } - - if funcValue.Type().NumIn() != len(arguments) { +func callFunc(fn reflect.Value, arguments ...interface{}) (interface{}, error) { + if fn.Type().NumIn() != len(arguments) { // function arguments not match - return nil, fmt.Errorf("function %s arguments number not match", funcName) + return nil, fmt.Errorf("function arguments number not match") } argumentsValue := make([]reflect.Value, len(arguments)) for index, argument := range arguments { argumentValue := reflect.ValueOf(argument) - expectArgumentType := funcValue.Type().In(index) + expectArgumentType := fn.Type().In(index) actualArgumentType := reflect.TypeOf(argument) // type match @@ -282,20 +311,18 @@ func callFunc(funcName string, arguments ...interface{}) (interface{}, error) { // type not match, check if convertible if !actualArgumentType.ConvertibleTo(expectArgumentType) { // function argument type not match and not convertible - err := fmt.Errorf("function %s argument %d type is neither match nor convertible, expect %v, actual %v", - funcName, index, expectArgumentType, actualArgumentType) - log.Error().Err(err).Msg("call function failed") + err := fmt.Errorf("function argument %d's type is neither match nor convertible, expect %v, actual %v", + index, expectArgumentType, actualArgumentType) return nil, err } // convert argument to expect type argumentsValue[index] = argumentValue.Convert(expectArgumentType) } - resultValues := funcValue.Call(argumentsValue) + resultValues := fn.Call(argumentsValue) if len(resultValues) > 1 { // function should return at most one value - err := fmt.Errorf("function %s should return at most one value", funcName) - log.Error().Err(err).Msg("call function failed") + err := fmt.Errorf("function should return at most one value") return nil, err } @@ -307,11 +334,6 @@ func callFunc(funcName string, arguments ...interface{}) (interface{}, error) { // return one value // convert reflect.Value to interface{} result := resultValues[0].Interface() - log.Info(). - Str("funcName", funcName). - Interface("arguments", arguments). - Interface("output", result). - Msg("call function success") return result, nil } @@ -361,7 +383,7 @@ func parseFunctionArguments(argsStr string) ([]interface{}, error) { return arguments, nil } -func parseVariables(variables map[string]interface{}) (map[string]interface{}, error) { +func parseVariables(variables map[string]interface{}, pluginLoader *pluginLoader) (map[string]interface{}, error) { parsedVariables := make(map[string]interface{}) var traverseRounds int @@ -399,7 +421,7 @@ func parseVariables(variables map[string]interface{}) (map[string]interface{}, e return variables, fmt.Errorf("variable not defined: %v", undefinedVars) } - parsedValue, err := parseData(varValue, parsedVariables) + parsedValue, err := parseData(varValue, parsedVariables, pluginLoader) if err != nil { continue } diff --git a/parser_test.go b/parser_test.go index 33f3ae76..4147bf5f 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,6 +1,7 @@ package hrp import ( + "plugin" "sort" "testing" "time" @@ -161,7 +162,7 @@ func TestParseDataStringWithVariables(t *testing.T) { } for _, data := range testData { - parsedData, err := parseData(data.expr, variablesMapping) + parsedData, err := parseData(data.expr, variablesMapping, nil) if !assert.NoError(t, err) { t.Fail() } @@ -185,7 +186,7 @@ func TestParseDataStringWithUndefinedVariables(t *testing.T) { } for _, data := range testData { - parsedData, err := parseData(data.expr, variablesMapping) + parsedData, err := parseData(data.expr, variablesMapping, nil) if !assert.Error(t, err) { t.Fail() } @@ -229,7 +230,7 @@ func TestParseDataStringWithVariablesAbnormal(t *testing.T) { } for _, data := range testData { - parsedData, err := parseData(data.expr, variablesMapping) + parsedData, err := parseData(data.expr, variablesMapping, nil) if !assert.NoError(t, err) { t.Fail() } @@ -259,7 +260,7 @@ func TestParseDataMapWithVariables(t *testing.T) { } for _, data := range testData { - parsedData, err := parseData(data.expr, variablesMapping) + parsedData, err := parseData(data.expr, variablesMapping, nil) if !assert.NoError(t, err) { t.Fail() } @@ -292,7 +293,7 @@ func TestParseHeaders(t *testing.T) { } for _, data := range testData { - parsedHeaders, err := parseHeaders(data.rawHeaders, variablesMapping) + parsedHeaders, err := parseHeaders(data.rawHeaders, variablesMapping, nil) if !assert.NoError(t, err) { t.Fail() } @@ -328,16 +329,18 @@ func TestMergeVariables(t *testing.T) { } } -func TestCallFunction(t *testing.T) { +func TestCallBuiltinFunction(t *testing.T) { // call function without arguments - _, err := callFunc("get_timestamp") + f1, _ := getMappingFunction("get_timestamp", nil) + _, err := callFunc(f1) if !assert.NoError(t, err) { t.Fail() } // call function with one argument timeStart := time.Now() - _, err = callFunc("sleep", 1) + f2, _ := getMappingFunction("sleep", nil) + _, err = callFunc(f2, 1) if !assert.NoError(t, err) { t.Fail() } @@ -346,7 +349,8 @@ func TestCallFunction(t *testing.T) { } // call function with one argument - result, err := callFunc("gen_random_string", 10) + f3, _ := getMappingFunction("gen_random_string", nil) + result, err := callFunc(f3, 10) if !assert.NoError(t, err) { t.Fail() } @@ -355,7 +359,8 @@ func TestCallFunction(t *testing.T) { } // call function with two argument - result, err = callFunc("max", float64(10), 9.99) + f4, _ := getMappingFunction("max", nil) + result, err = callFunc(f4, float64(10), 9.99) if !assert.NoError(t, err) { t.Fail() } @@ -364,6 +369,24 @@ func TestCallFunction(t *testing.T) { } } +func TestCallPluginFunction(t *testing.T) { + plugins, err := plugin.Open("examples/debugtalk.so") + if err != nil { + t.Fatalf(err.Error()) + } + pluginLoader := &pluginLoader{plugins} + + // call function without arguments + f1, _ := getMappingFunction("Concatenate", pluginLoader) + result, err := callFunc(f1, 1, "2", 3.14) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, result, "1_2_3.14") { + t.Fail() + } +} + func TestLiteralEval(t *testing.T) { testData := []struct { expr string @@ -441,7 +464,7 @@ func TestParseDataStringWithFunctions(t *testing.T) { } for _, data := range testData1 { - value, err := parseData(data.expr, variablesMapping) + value, err := parseData(data.expr, variablesMapping, nil) if !assert.NoError(t, err) { t.Fail() } @@ -460,7 +483,7 @@ func TestParseDataStringWithFunctions(t *testing.T) { } for _, data := range testData2 { - value, err := parseData(data.expr, variablesMapping) + value, err := parseData(data.expr, variablesMapping, nil) if !assert.NoError(t, err) { t.Fail() } @@ -507,7 +530,7 @@ func TestParseVariables(t *testing.T) { } for _, data := range testData { - value, err := parseVariables(data.rawVars) + value, err := parseVariables(data.rawVars, nil) if !assert.NoError(t, err) { t.Fail() } @@ -537,7 +560,7 @@ func TestParseVariablesAbnormal(t *testing.T) { } for _, data := range testData { - value, err := parseVariables(data.rawVars) + value, err := parseVariables(data.rawVars, nil) if !assert.Error(t, err) { t.Fail() } diff --git a/response.go b/response.go index f81de3f1..ce036164 100644 --- a/response.go +++ b/response.go @@ -13,7 +13,7 @@ import ( "github.com/httprunner/hrp/internal/builtin" ) -func newResponseObject(t *testing.T, resp *http.Response) (*responseObject, error) { +func newResponseObject(t *testing.T, pluginLoader *pluginLoader, resp *http.Response) (*responseObject, error) { // prepare response headers headers := make(map[string]string) for k, v := range resp.Header { @@ -60,8 +60,9 @@ func newResponseObject(t *testing.T, resp *http.Response) (*responseObject, erro } return &responseObject{ - t: t, - respObjMeta: data, + t: t, + pluginLoader: pluginLoader, + respObjMeta: data, }, nil } @@ -74,6 +75,7 @@ type respObjMeta struct { type responseObject struct { t *testing.T + pluginLoader *pluginLoader respObjMeta interface{} validationResults map[string]interface{} } @@ -101,7 +103,7 @@ func (v *responseObject) Validate(validators []Validator, variablesMapping map[s var checkValue interface{} if strings.Contains(checkItem, "$") { // reference variable - checkValue, err = parseData(checkItem, variablesMapping) + checkValue, err = parseData(checkItem, variablesMapping, v.pluginLoader) if err != nil { return err } @@ -114,7 +116,7 @@ func (v *responseObject) Validate(validators []Validator, variablesMapping map[s assertFunc := builtin.Assertions[assertMethod] // parse expected value - expectValue, err := parseData(validator.Expect, variablesMapping) + expectValue, err := parseData(validator.Expect, variablesMapping, v.pluginLoader) if err != nil { return err } diff --git a/runner.go b/runner.go index bca1900b..1b804911 100644 --- a/runner.go +++ b/runner.go @@ -9,6 +9,9 @@ import ( "net/http" "net/http/httputil" "net/url" + "os" + "path/filepath" + "plugin" "strconv" "strings" "testing" @@ -125,6 +128,8 @@ type caseRunner struct { // key is transaction name, value is map of transaction type and time, e.g. start time and end time. transactions map[string]map[transactionType]time.Time startTime time.Time // record start time of the testcase + // pluginLoader stores loaded go plugins. + pluginLoader *pluginLoader } // reset clears runner session variables. @@ -194,7 +199,7 @@ func (r *caseRunner) runStep(index int) (stepResult *stepData, err error) { stepVariables = mergeVariables(stepVariables, copiedConfig.Variables) // parse step variables - parsedVariables, err := parseVariables(stepVariables) + parsedVariables, err := parseVariables(stepVariables, r.pluginLoader) if err != nil { log.Error().Interface("variables", copiedConfig.Variables).Err(err).Msg("parse step variables failed") return nil, err @@ -311,7 +316,7 @@ func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err erro // prepare request headers if len(step.Request.Headers) > 0 { - headers, err := parseHeaders(step.Request.Headers, step.Variables) + headers, err := parseHeaders(step.Request.Headers, step.Variables, r.pluginLoader) if err != nil { return nil, errors.Wrap(err, "parse headers failed") } @@ -328,7 +333,7 @@ func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err erro // prepare request params var queryParams url.Values if len(step.Request.Params) > 0 { - params, err := parseData(step.Request.Params, step.Variables) + params, err := parseData(step.Request.Params, step.Variables, r.pluginLoader) if err != nil { return nil, errors.Wrap(err, "parse data failed") } @@ -360,7 +365,7 @@ func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err erro // prepare request body if step.Request.Body != nil { - data, err := parseData(step.Request.Body, step.Variables) + data, err := parseData(step.Request.Body, step.Variables, r.pluginLoader) if err != nil { return nil, err } @@ -434,7 +439,7 @@ func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err erro } // new response object - respObj, err := newResponseObject(r.hrpRunner.t, resp) + respObj, err := newResponseObject(r.hrpRunner.t, r.pluginLoader, resp) if err != nil { err = errors.Wrap(err, "init ResponseObject error") return @@ -479,22 +484,28 @@ func (r *caseRunner) runStepTestCase(step *TStep) (stepResult *stepData, err err func (r *caseRunner) parseConfig(config IConfig) error { cfg := config.ToStruct() // parse config variables - parsedVariables, err := parseVariables(cfg.Variables) + parsedVariables, err := parseVariables(cfg.Variables, r.pluginLoader) if err != nil { log.Error().Interface("variables", cfg.Variables).Err(err).Msg("parse config variables failed") return err } cfg.Variables = parsedVariables + // load plugin variables and functions + err = r.loadPlugin(cfg.Path) + if err != nil { + return err + } + // parse config name - parsedName, err := parseString(cfg.Name, cfg.Variables) + parsedName, err := parseString(cfg.Name, cfg.Variables, r.pluginLoader) if err != nil { return err } cfg.Name = convertString(parsedName) // parse config base url - parsedBaseURL, err := parseString(cfg.BaseURL, cfg.Variables) + parsedBaseURL, err := parseString(cfg.BaseURL, cfg.Variables, r.pluginLoader) if err != nil { return err } @@ -503,10 +514,81 @@ func (r *caseRunner) parseConfig(config IConfig) error { return nil } -func (r *caseRunner) getSummary() *testCaseSummary { +type pluginLoader struct { + *plugin.Plugin +} + +func (r *caseRunner) loadPlugin(path string) error { + if path == "" { + return nil + } + + // check if loaded before + if r.pluginLoader != nil { + return nil + } + + // locate plugin file + pluginPath, err := locatePlugin(path) + if err != nil { + // plugin not found + return nil + } + + // load plugin + plugins, err := plugin.Open(pluginPath) + if err != nil { + log.Error().Err(err).Str("path", path).Msg("load go plugin failed") + return err + } + r.pluginLoader = &pluginLoader{plugins} + + log.Info().Str("path", path).Msg("load go plugin success") + return nil +} + +func (r *hrpRunner) getSummary() *testCaseSummary { return &testCaseSummary{} } +// locatePlugin +// searching will be recursive upward until current working directory or system root dir. +func locatePlugin(startPath string) (string, error) { + stat, err := os.Stat(startPath) + if os.IsNotExist(err) { + return "", err + } + + var startDir string + if stat.IsDir() { + startDir = startPath + } else { + startDir = filepath.Dir(startPath) + } + startDir, _ = filepath.Abs(startDir) + + // convention over configuration + // target plugin file name is always debugtalk.so + pluginPath := filepath.Join(startDir, "debugtalk.so") + if _, err := os.Stat(pluginPath); err == nil { + return pluginPath, nil + } + + // current working directory + cwd, _ := os.Getwd() + if startDir == cwd { + return "", fmt.Errorf("searched to CWD, plugin file not found") + } + + // system root dir + parentDir, _ := filepath.Abs(filepath.Dir(startDir)) + if parentDir == startDir { + return "", fmt.Errorf("searched to system root dir, plugin file not found") + } + + return locatePlugin(parentDir) +} + func setBodyBytes(req *http.Request, data []byte) { req.Body = ioutil.NopCloser(bytes.NewReader(data)) req.ContentLength = int64(len(data)) diff --git a/runner_test.go b/runner_test.go index 686d67e1..80770b4b 100644 --- a/runner_test.go +++ b/runner_test.go @@ -1,9 +1,66 @@ package hrp import ( + "fmt" + "os" + "os/exec" "testing" + + "github.com/stretchr/testify/assert" ) +func TestMain(m *testing.M) { + fmt.Println("[TestMain] build go plugin") + cmd := exec.Command("go", "build", "-buildmode=plugin", "-o=examples/debugtalk.so", "examples/plugin/debugtalk.go") + if err := cmd.Run(); err != nil { + panic(err) + } + os.Exit(m.Run()) +} + +func TestLocatePlugin(t *testing.T) { + cwd, _ := os.Getwd() + _, err := locatePlugin(cwd) + if !assert.Error(t, err) { + t.Fail() + } + + _, err = locatePlugin("") + if !assert.Error(t, err) { + t.Fail() + } + + startPath := "examples/debugtalk.so" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "examples/demo.json" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "examples/" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "examples/plugin/debugtalk.go" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "/abc" + _, err = locatePlugin(startPath) + if !assert.Error(t, err) { + t.Fail() + } +} + func TestHttpRunner(t *testing.T) { testcase1 := &TestCase{ Config: NewConfig("TestCase1"). From f2b732a78c53ed0cb739a0516023c042b81030c9 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 28 Dec 2021 22:26:27 +0800 Subject: [PATCH 2/8] change: skip go plugin test for windows --- parser_test.go | 19 ------------------- plugin_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ runner.go | 10 ++++++++-- runner_test.go | 11 ----------- 4 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 plugin_test.go diff --git a/parser_test.go b/parser_test.go index 4147bf5f..d7629fd8 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,7 +1,6 @@ package hrp import ( - "plugin" "sort" "testing" "time" @@ -369,24 +368,6 @@ func TestCallBuiltinFunction(t *testing.T) { } } -func TestCallPluginFunction(t *testing.T) { - plugins, err := plugin.Open("examples/debugtalk.so") - if err != nil { - t.Fatalf(err.Error()) - } - pluginLoader := &pluginLoader{plugins} - - // call function without arguments - f1, _ := getMappingFunction("Concatenate", pluginLoader) - result, err := callFunc(f1, 1, "2", 3.14) - if !assert.NoError(t, err) { - t.Fail() - } - if !assert.Equal(t, result, "1_2_3.14") { - t.Fail() - } -} - func TestLiteralEval(t *testing.T) { testData := []struct { expr string diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 00000000..eebfc59a --- /dev/null +++ b/plugin_test.go @@ -0,0 +1,40 @@ +// +build linux freebsd darwin +// go plugin doesn't support windows +package hrp + +import ( + "fmt" + "os" + "os/exec" + "plugin" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + fmt.Println("[TestMain] build go plugin") + cmd := exec.Command("go", "build", "-buildmode=plugin", "-o=examples/debugtalk.so", "examples/plugin/debugtalk.go") + if err := cmd.Run(); err != nil { + panic(err) + } + os.Exit(m.Run()) +} + +func TestCallPluginFunction(t *testing.T) { + plugins, err := plugin.Open("examples/debugtalk.so") + if err != nil { + t.Fatalf(err.Error()) + } + pluginLoader := &pluginLoader{plugins} + + // call function without arguments + f1, _ := getMappingFunction("Concatenate", pluginLoader) + result, err := callFunc(f1, 1, "2", 3.14) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, result, "1_2_3.14") { + t.Fail() + } +} diff --git a/runner.go b/runner.go index 1b804911..737b33fc 100644 --- a/runner.go +++ b/runner.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "plugin" + "runtime" "strconv" "strings" "testing" @@ -519,6 +520,11 @@ type pluginLoader struct { } func (r *caseRunner) loadPlugin(path string) error { + if runtime.GOOS == "windows" { + log.Warn().Msg("go plugin does not support windows") + return nil + } + if path == "" { return nil } @@ -551,8 +557,8 @@ func (r *hrpRunner) getSummary() *testCaseSummary { return &testCaseSummary{} } -// locatePlugin -// searching will be recursive upward until current working directory or system root dir. +// locatePlugin searches debugtalk.so upward recursively until current +// working directory or system root dir. func locatePlugin(startPath string) (string, error) { stat, err := os.Stat(startPath) if os.IsNotExist(err) { diff --git a/runner_test.go b/runner_test.go index 80770b4b..4318a754 100644 --- a/runner_test.go +++ b/runner_test.go @@ -1,23 +1,12 @@ package hrp import ( - "fmt" "os" - "os/exec" "testing" "github.com/stretchr/testify/assert" ) -func TestMain(m *testing.M) { - fmt.Println("[TestMain] build go plugin") - cmd := exec.Command("go", "build", "-buildmode=plugin", "-o=examples/debugtalk.so", "examples/plugin/debugtalk.go") - if err := cmd.Run(); err != nil { - panic(err) - } - os.Exit(m.Run()) -} - func TestLocatePlugin(t *testing.T) { cwd, _ := os.Getwd() _, err := locatePlugin(cwd) From ae53e60a5018e91fee07c0cb5ca39be4553b67ba Mon Sep 17 00:00:00 2001 From: debugtalk Date: Tue, 28 Dec 2021 22:51:01 +0800 Subject: [PATCH 3/8] fix: unittest --- docs/cmd/hrp.md | 2 +- docs/cmd/hrp_boom.md | 2 +- docs/cmd/hrp_har2case.md | 2 +- docs/cmd/hrp_run.md | 2 +- plugin_test.go | 7 ++++++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/cmd/hrp.md b/docs/cmd/hrp.md index b081b919..82c1c91f 100644 --- a/docs/cmd/hrp.md +++ b/docs/cmd/hrp.md @@ -32,4 +32,4 @@ Copyright 2021 debugtalk * [hrp har2case](hrp_har2case.md) - Convert HAR to json/yaml testcase files * [hrp run](hrp_run.md) - run API test -###### Auto generated by spf13/cobra on 28-Dec-2021 +###### Auto generated by spf13/cobra on 29-Dec-2021 diff --git a/docs/cmd/hrp_boom.md b/docs/cmd/hrp_boom.md index c707dacf..524e00de 100644 --- a/docs/cmd/hrp_boom.md +++ b/docs/cmd/hrp_boom.md @@ -38,4 +38,4 @@ hrp boom [flags] * [hrp](hrp.md) - One-stop solution for HTTP(S) testing. -###### Auto generated by spf13/cobra on 28-Dec-2021 +###### Auto generated by spf13/cobra on 29-Dec-2021 diff --git a/docs/cmd/hrp_har2case.md b/docs/cmd/hrp_har2case.md index 76921935..3944abd9 100644 --- a/docs/cmd/hrp_har2case.md +++ b/docs/cmd/hrp_har2case.md @@ -23,4 +23,4 @@ hrp har2case harPath... [flags] * [hrp](hrp.md) - One-stop solution for HTTP(S) testing. -###### Auto generated by spf13/cobra on 28-Dec-2021 +###### Auto generated by spf13/cobra on 29-Dec-2021 diff --git a/docs/cmd/hrp_run.md b/docs/cmd/hrp_run.md index f4ab234b..1d45d99c 100644 --- a/docs/cmd/hrp_run.md +++ b/docs/cmd/hrp_run.md @@ -31,4 +31,4 @@ hrp run path... [flags] * [hrp](hrp.md) - One-stop solution for HTTP(S) testing. -###### Auto generated by spf13/cobra on 28-Dec-2021 +###### Auto generated by spf13/cobra on 29-Dec-2021 diff --git a/plugin_test.go b/plugin_test.go index eebfc59a..ec606473 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -1,5 +1,6 @@ // +build linux freebsd darwin // go plugin doesn't support windows + package hrp import ( @@ -14,7 +15,11 @@ import ( func TestMain(m *testing.M) { fmt.Println("[TestMain] build go plugin") - cmd := exec.Command("go", "build", "-buildmode=plugin", "-o=examples/debugtalk.so", "examples/plugin/debugtalk.go") + // go tool compile -help + // all=: apply to all packages + // -N: disable optimizations + // -l: disable inlining + cmd := exec.Command("go", "build", "-buildmode=plugin", `-gcflags="all=-N -l"`, "-o=examples/debugtalk.so", "examples/plugin/debugtalk.go") if err := cmd.Run(); err != nil { panic(err) } From 75ff0962c962bbb9e871fc0327801d0834a89d03 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 6 Jan 2022 14:07:13 +0800 Subject: [PATCH 4/8] bump version to v0.5.0 --- docs/CHANGELOG.md | 5 ++++- internal/version/init.go | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 066ac6e2..7023b21f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,9 +1,12 @@ # Release History +## v0.5.0 (2022-01-06) + +- feat: support creating and calling custom functions with [go plugin](https://pkg.go.dev/plugin) + ## v0.4.0 (2022-01-05) - feat: implement `parameterize` mechanism for data driven -- feat: support creating and calling custom functions with [go plugin](https://pkg.go.dev/plugin) - feat: add multiple builtin assertion methods and builtin functions ## v0.3.1 (2021-12-30) diff --git a/internal/version/init.go b/internal/version/init.go index 462025a5..8b4b8520 100644 --- a/internal/version/init.go +++ b/internal/version/init.go @@ -1,3 +1,3 @@ package version -const VERSION = "v0.4.0" +const VERSION = "v0.5.0" From cfad6cee59e68a40866e4343258725b0d1a36bc8 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 6 Jan 2022 17:17:19 +0800 Subject: [PATCH 5/8] refactor: group parser functions --- parser.go | 113 +++++++++++++++++++++++++++++++++++++++++++------ parser_test.go | 70 ++++++++++++++++++++++++++---- plugin_test.go | 3 +- response.go | 14 +++--- runner.go | 102 +++++--------------------------------------- runner_test.go | 46 -------------------- 6 files changed, 179 insertions(+), 169 deletions(-) diff --git a/parser.go b/parser.go index 7b9a9727..03dc0140 100644 --- a/parser.go +++ b/parser.go @@ -4,8 +4,12 @@ import ( "encoding/json" "fmt" "net/url" + "os" + "path/filepath" + "plugin" "reflect" "regexp" + "runtime" "strings" "github.com/maja42/goval" @@ -15,6 +19,87 @@ import ( "github.com/httprunner/hrp/internal/builtin" ) +func newParser() *parser { + return &parser{} +} + +type parser struct { + // pluginLoader stores loaded go plugins. + pluginLoader *plugin.Plugin +} + +func (p *parser) loadPlugin(path string) error { + if runtime.GOOS == "windows" { + log.Warn().Msg("go plugin does not support windows") + return nil + } + + if path == "" { + return nil + } + + // check if loaded before + if p.pluginLoader != nil { + return nil + } + + // locate plugin file + pluginPath, err := locatePlugin(path) + if err != nil { + // plugin not found + return nil + } + + // load plugin + plugins, err := plugin.Open(pluginPath) + if err != nil { + log.Error().Err(err).Str("path", path).Msg("load go plugin failed") + return err + } + p.pluginLoader = plugins + + log.Info().Str("path", path).Msg("load go plugin success") + return nil +} + +// locatePlugin searches debugtalk.so upward recursively until current +// working directory or system root dir. +func locatePlugin(startPath string) (string, error) { + stat, err := os.Stat(startPath) + if os.IsNotExist(err) { + return "", err + } + + var startDir string + if stat.IsDir() { + startDir = startPath + } else { + startDir = filepath.Dir(startPath) + } + startDir, _ = filepath.Abs(startDir) + + // convention over configuration + // target plugin file name is always debugtalk.so + pluginPath := filepath.Join(startDir, "debugtalk.so") + if _, err := os.Stat(pluginPath); err == nil { + return pluginPath, nil + } + + // current working directory + cwd, _ := os.Getwd() + if startDir == cwd { + return "", fmt.Errorf("searched to CWD, plugin file not found") + } + + // system root dir + parentDir, _ := filepath.Abs(filepath.Dir(startDir)) + if parentDir == startDir { + return "", fmt.Errorf("searched to system root dir, plugin file not found") + } + + return locatePlugin(parentDir) +} + func buildURL(baseURL, stepURL string) string { uConfig, err := url.Parse(baseURL) if err != nil { @@ -32,9 +117,9 @@ func buildURL(baseURL, stepURL string) string { return uStep.String() } -func parseHeaders(rawHeaders map[string]string, variablesMapping map[string]interface{}, pluginLoader *pluginLoader) (map[string]string, error) { +func (p *parser) parseHeaders(rawHeaders map[string]string, variablesMapping map[string]interface{}) (map[string]string, error) { parsedHeaders := make(map[string]string) - headers, err := parseData(rawHeaders, variablesMapping, pluginLoader) + headers, err := p.parseData(rawHeaders, variablesMapping) if err != nil { return rawHeaders, err } @@ -54,7 +139,7 @@ func convertString(raw interface{}) string { } } -func parseData(raw interface{}, variablesMapping map[string]interface{}, pluginLoader *pluginLoader) (interface{}, error) { +func (p *parser) parseData(raw interface{}, variablesMapping map[string]interface{}) (interface{}, error) { rawValue := reflect.ValueOf(raw) switch rawValue.Kind() { case reflect.String: @@ -65,11 +150,11 @@ func parseData(raw interface{}, variablesMapping map[string]interface{}, pluginL // other string value := rawValue.String() value = strings.TrimSpace(value) - return parseString(value, variablesMapping, pluginLoader) + return p.parseString(value, variablesMapping) case reflect.Slice: parsedSlice := make([]interface{}, rawValue.Len()) for i := 0; i < rawValue.Len(); i++ { - parsedValue, err := parseData(rawValue.Index(i).Interface(), variablesMapping, pluginLoader) + parsedValue, err := p.parseData(rawValue.Index(i).Interface(), variablesMapping) if err != nil { return raw, err } @@ -79,12 +164,12 @@ func parseData(raw interface{}, variablesMapping map[string]interface{}, pluginL case reflect.Map: // convert any map to map[string]interface{} parsedMap := make(map[string]interface{}) for _, k := range rawValue.MapKeys() { - parsedKey, err := parseString(k.String(), variablesMapping, pluginLoader) + parsedKey, err := p.parseString(k.String(), variablesMapping) if err != nil { return raw, err } v := rawValue.MapIndex(k) - parsedValue, err := parseData(v.Interface(), variablesMapping, pluginLoader) + parsedValue, err := p.parseData(v.Interface(), variablesMapping) if err != nil { return raw, err } @@ -122,7 +207,7 @@ var ( ) // parseString parse string with variables -func parseString(raw string, variablesMapping map[string]interface{}, pluginLoader *pluginLoader) (interface{}, error) { +func (p *parser) parseString(raw string, variablesMapping map[string]interface{}) (interface{}, error) { matchStartPosition := 0 parsedString := "" remainedString := raw @@ -161,12 +246,12 @@ func parseString(raw string, variablesMapping map[string]interface{}, pluginLoad if err != nil { return raw, err } - parsedArgs, err := parseData(arguments, variablesMapping, pluginLoader) + parsedArgs, err := p.parseData(arguments, variablesMapping) if err != nil { return raw, err } - fn, err := getMappingFunction(funcName, pluginLoader) + fn, err := getMappingFunction(funcName, p.pluginLoader) if err != nil { return raw, err } @@ -257,7 +342,7 @@ func mergeVariables(variables, overriddenVariables map[string]interface{}) map[s return mergedVariables } -func getMappingFunction(funcName string, pluginLoader *pluginLoader) (reflect.Value, error) { +func getMappingFunction(funcName string, pluginLoader *plugin.Plugin) (reflect.Value, error) { var fn reflect.Value var err error @@ -384,7 +469,7 @@ func parseFunctionArguments(argsStr string) ([]interface{}, error) { return arguments, nil } -func parseVariables(variables map[string]interface{}, pluginLoader *pluginLoader) (map[string]interface{}, error) { +func (p *parser) parseVariables(variables map[string]interface{}) (map[string]interface{}, error) { parsedVariables := make(map[string]interface{}) var traverseRounds int @@ -422,7 +507,7 @@ func parseVariables(variables map[string]interface{}, pluginLoader *pluginLoader return variables, fmt.Errorf("variable not defined: %v", undefinedVars) } - parsedValue, err := parseData(varValue, parsedVariables, pluginLoader) + parsedValue, err := p.parseData(varValue, parsedVariables) if err != nil { continue } @@ -552,7 +637,7 @@ func parseParameters(parameters map[string]interface{}, variablesMapping map[str case reflect.String: // e.g. username-password: ${parameterize(examples/account.csv)} -> [{"username": "test1", "password": "111111"}, {"username": "test2", "password": "222222"}] var parsedParameterContent interface{} - parsedParameterContent, err = parseString(rawValue.String(), variablesMapping, nil) + parsedParameterContent, err = newParser().parseString(rawValue.String(), variablesMapping) if err != nil { log.Error().Interface("parameterContent", rawValue).Msg("[parseParameters] parse parameter content error") return nil, err diff --git a/parser_test.go b/parser_test.go index abb0136a..70ffe899 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,6 +1,7 @@ package hrp import ( + "os" "sort" "testing" "time" @@ -8,6 +9,49 @@ import ( "github.com/stretchr/testify/assert" ) +func TestLocatePlugin(t *testing.T) { + cwd, _ := os.Getwd() + _, err := locatePlugin(cwd) + if !assert.Error(t, err) { + t.Fail() + } + + _, err = locatePlugin("") + if !assert.Error(t, err) { + t.Fail() + } + + startPath := "examples/debugtalk.so" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "examples/demo.json" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "examples/" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "examples/plugin/debugtalk.go" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "/abc" + _, err = locatePlugin(startPath) + if !assert.Error(t, err) { + t.Fail() + } +} + func TestBuildURL(t *testing.T) { var url string url = buildURL("https://postman-echo.com", "/get") @@ -160,8 +204,9 @@ func TestParseDataStringWithVariables(t *testing.T) { {"abc$var_5", "abctrue"}, // "abcTrue" } + parser := newParser() for _, data := range testData { - parsedData, err := parseData(data.expr, variablesMapping, nil) + parsedData, err := parser.parseData(data.expr, variablesMapping) if !assert.NoError(t, err) { t.Fail() } @@ -184,8 +229,9 @@ func TestParseDataStringWithUndefinedVariables(t *testing.T) { {"/api/$SECRET_KEY", "/api/$SECRET_KEY"}, // raise error } + parser := newParser() for _, data := range testData { - parsedData, err := parseData(data.expr, variablesMapping, nil) + parsedData, err := parser.parseData(data.expr, variablesMapping) if !assert.Error(t, err) { t.Fail() } @@ -228,8 +274,9 @@ func TestParseDataStringWithVariablesAbnormal(t *testing.T) { {"ABC$var_1{}a", "ABCabc{}a"}, // {} } + parser := newParser() for _, data := range testData { - parsedData, err := parseData(data.expr, variablesMapping, nil) + parsedData, err := parser.parseData(data.expr, variablesMapping) if !assert.NoError(t, err) { t.Fail() } @@ -258,8 +305,9 @@ func TestParseDataMapWithVariables(t *testing.T) { {map[string]interface{}{"$var2": "$val1"}, map[string]interface{}{"123": 200}}, } + parser := newParser() for _, data := range testData { - parsedData, err := parseData(data.expr, variablesMapping, nil) + parsedData, err := parser.parseData(data.expr, variablesMapping) if !assert.NoError(t, err) { t.Fail() } @@ -291,8 +339,9 @@ func TestParseHeaders(t *testing.T) { {map[string]string{"$var2": "$val2"}, map[string]string{"123": ""}}, } + parser := newParser() for _, data := range testData { - parsedHeaders, err := parseHeaders(data.rawHeaders, variablesMapping, nil) + parsedHeaders, err := parser.parseHeaders(data.rawHeaders, variablesMapping) if !assert.NoError(t, err) { t.Fail() } @@ -444,8 +493,9 @@ func TestParseDataStringWithFunctions(t *testing.T) { {"123${gen_random_string($n)}abc", 11}, } + parser := newParser() for _, data := range testData1 { - value, err := parseData(data.expr, variablesMapping, nil) + value, err := parser.parseData(data.expr, variablesMapping) if !assert.NoError(t, err) { t.Fail() } @@ -464,7 +514,7 @@ func TestParseDataStringWithFunctions(t *testing.T) { } for _, data := range testData2 { - value, err := parseData(data.expr, variablesMapping, nil) + value, err := parser.parseData(data.expr, variablesMapping) if !assert.NoError(t, err) { t.Fail() } @@ -510,8 +560,9 @@ func TestParseVariables(t *testing.T) { }, } + parser := newParser() for _, data := range testData { - value, err := parseVariables(data.rawVars, nil) + value, err := parser.parseVariables(data.rawVars) if !assert.NoError(t, err) { t.Fail() } @@ -540,8 +591,9 @@ func TestParseVariablesAbnormal(t *testing.T) { }, } + parser := newParser() for _, data := range testData { - value, err := parseVariables(data.rawVars, nil) + value, err := parser.parseVariables(data.rawVars) if !assert.Error(t, err) { t.Fail() } diff --git a/plugin_test.go b/plugin_test.go index ec606473..75fc271d 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -27,11 +27,10 @@ func TestMain(m *testing.M) { } func TestCallPluginFunction(t *testing.T) { - plugins, err := plugin.Open("examples/debugtalk.so") + pluginLoader, err := plugin.Open("examples/debugtalk.so") if err != nil { t.Fatalf(err.Error()) } - pluginLoader := &pluginLoader{plugins} // call function without arguments f1, _ := getMappingFunction("Concatenate", pluginLoader) diff --git a/response.go b/response.go index ce036164..e57608fe 100644 --- a/response.go +++ b/response.go @@ -13,7 +13,7 @@ import ( "github.com/httprunner/hrp/internal/builtin" ) -func newResponseObject(t *testing.T, pluginLoader *pluginLoader, resp *http.Response) (*responseObject, error) { +func newResponseObject(t *testing.T, parser *parser, resp *http.Response) (*responseObject, error) { // prepare response headers headers := make(map[string]string) for k, v := range resp.Header { @@ -60,9 +60,9 @@ func newResponseObject(t *testing.T, pluginLoader *pluginLoader, resp *http.Resp } return &responseObject{ - t: t, - pluginLoader: pluginLoader, - respObjMeta: data, + t: t, + parser: parser, + respObjMeta: data, }, nil } @@ -75,7 +75,7 @@ type respObjMeta struct { type responseObject struct { t *testing.T - pluginLoader *pluginLoader + parser *parser respObjMeta interface{} validationResults map[string]interface{} } @@ -103,7 +103,7 @@ func (v *responseObject) Validate(validators []Validator, variablesMapping map[s var checkValue interface{} if strings.Contains(checkItem, "$") { // reference variable - checkValue, err = parseData(checkItem, variablesMapping, v.pluginLoader) + checkValue, err = v.parser.parseData(checkItem, variablesMapping) if err != nil { return err } @@ -116,7 +116,7 @@ func (v *responseObject) Validate(validators []Validator, variablesMapping map[s assertFunc := builtin.Assertions[assertMethod] // parse expected value - expectValue, err := parseData(validator.Expect, variablesMapping, v.pluginLoader) + expectValue, err := v.parser.parseData(validator.Expect, variablesMapping) if err != nil { return err } diff --git a/runner.go b/runner.go index a6d425dc..644d2c5e 100644 --- a/runner.go +++ b/runner.go @@ -9,10 +9,6 @@ import ( "net/http" "net/http/httputil" "net/url" - "os" - "path/filepath" - "plugin" - "runtime" "strconv" "strings" "testing" @@ -130,6 +126,7 @@ func (r *HRPRunner) newCaseRunner(testcase *TestCase) *caseRunner { caseRunner := &caseRunner{ TestCase: testcase, hrpRunner: r, + parser: newParser(), } caseRunner.reset() return caseRunner @@ -140,13 +137,12 @@ func (r *HRPRunner) newCaseRunner(testcase *TestCase) *caseRunner { type caseRunner struct { *TestCase hrpRunner *HRPRunner + parser *parser sessionVariables map[string]interface{} // transactions stores transaction timing info. // key is transaction name, value is map of transaction type and time, e.g. start time and end time. transactions map[string]map[transactionType]time.Time startTime time.Time // record start time of the testcase - // pluginLoader stores loaded go plugins. - pluginLoader *pluginLoader } // reset clears runner session variables. @@ -209,7 +205,7 @@ func (r *caseRunner) runStep(index int, caseConfig *TConfig) (stepResult *stepDa stepVariables = mergeVariables(stepVariables, caseConfig.Variables) // parse step variables - parsedVariables, err := parseVariables(stepVariables, r.pluginLoader) + parsedVariables, err := r.parser.parseVariables(stepVariables) if err != nil { log.Error().Interface("variables", caseConfig.Variables).Err(err).Msg("parse step variables failed") return nil, err @@ -326,7 +322,7 @@ func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err erro // prepare request headers if len(step.Request.Headers) > 0 { - headers, err := parseHeaders(step.Request.Headers, step.Variables, r.pluginLoader) + headers, err := r.parser.parseHeaders(step.Request.Headers, step.Variables) if err != nil { return nil, errors.Wrap(err, "parse headers failed") } @@ -343,7 +339,7 @@ func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err erro // prepare request params var queryParams url.Values if len(step.Request.Params) > 0 { - params, err := parseData(step.Request.Params, step.Variables, r.pluginLoader) + params, err := r.parser.parseData(step.Request.Params, step.Variables) if err != nil { return nil, errors.Wrap(err, "parse data failed") } @@ -375,7 +371,7 @@ func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err erro // prepare request body if step.Request.Body != nil { - data, err := parseData(step.Request.Body, step.Variables, r.pluginLoader) + data, err := r.parser.parseData(step.Request.Body, step.Variables) if err != nil { return nil, err } @@ -449,7 +445,7 @@ func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err erro } // new response object - respObj, err := newResponseObject(r.hrpRunner.t, r.pluginLoader, resp) + respObj, err := newResponseObject(r.hrpRunner.t, r.parser, resp) if err != nil { err = errors.Wrap(err, "init ResponseObject error") return @@ -494,7 +490,7 @@ func (r *caseRunner) runStepTestCase(step *TStep) (stepResult *stepData, err err func (r *caseRunner) parseConfig(config IConfig) error { cfg := config.ToStruct() // parse config variables - parsedVariables, err := parseVariables(cfg.Variables, r.pluginLoader) + parsedVariables, err := r.parser.parseVariables(cfg.Variables) if err != nil { log.Error().Interface("variables", cfg.Variables).Err(err).Msg("parse config variables failed") return err @@ -502,20 +498,20 @@ func (r *caseRunner) parseConfig(config IConfig) error { cfg.Variables = parsedVariables // load plugin variables and functions - err = r.loadPlugin(cfg.Path) + err = r.parser.loadPlugin(cfg.Path) if err != nil { return err } // parse config name - parsedName, err := parseString(cfg.Name, cfg.Variables, r.pluginLoader) + parsedName, err := r.parser.parseString(cfg.Name, cfg.Variables) if err != nil { return err } cfg.Name = convertString(parsedName) // parse config base url - parsedBaseURL, err := parseString(cfg.BaseURL, cfg.Variables, r.pluginLoader) + parsedBaseURL, err := r.parser.parseString(cfg.BaseURL, cfg.Variables) if err != nil { return err } @@ -524,86 +520,10 @@ func (r *caseRunner) parseConfig(config IConfig) error { return nil } -type pluginLoader struct { - *plugin.Plugin -} - -func (r *caseRunner) loadPlugin(path string) error { - if runtime.GOOS == "windows" { - log.Warn().Msg("go plugin does not support windows") - return nil - } - - if path == "" { - return nil - } - - // check if loaded before - if r.pluginLoader != nil { - return nil - } - - // locate plugin file - pluginPath, err := locatePlugin(path) - if err != nil { - // plugin not found - return nil - } - - // load plugin - plugins, err := plugin.Open(pluginPath) - if err != nil { - log.Error().Err(err).Str("path", path).Msg("load go plugin failed") - return err - } - r.pluginLoader = &pluginLoader{plugins} - - log.Info().Str("path", path).Msg("load go plugin success") - return nil -} - func (r *caseRunner) getSummary() *testCaseSummary { return &testCaseSummary{} } -// locatePlugin searches debugtalk.so upward recursively until current -// working directory or system root dir. -func locatePlugin(startPath string) (string, error) { - stat, err := os.Stat(startPath) - if os.IsNotExist(err) { - return "", err - } - - var startDir string - if stat.IsDir() { - startDir = startPath - } else { - startDir = filepath.Dir(startPath) - } - startDir, _ = filepath.Abs(startDir) - - // convention over configuration - // target plugin file name is always debugtalk.so - pluginPath := filepath.Join(startDir, "debugtalk.so") - if _, err := os.Stat(pluginPath); err == nil { - return pluginPath, nil - } - - // current working directory - cwd, _ := os.Getwd() - if startDir == cwd { - return "", fmt.Errorf("searched to CWD, plugin file not found") - } - - // system root dir - parentDir, _ := filepath.Abs(filepath.Dir(startDir)) - if parentDir == startDir { - return "", fmt.Errorf("searched to system root dir, plugin file not found") - } - - return locatePlugin(parentDir) -} - func setBodyBytes(req *http.Request, data []byte) { req.Body = ioutil.NopCloser(bytes.NewReader(data)) req.ContentLength = int64(len(data)) diff --git a/runner_test.go b/runner_test.go index 4318a754..686d67e1 100644 --- a/runner_test.go +++ b/runner_test.go @@ -1,55 +1,9 @@ package hrp import ( - "os" "testing" - - "github.com/stretchr/testify/assert" ) -func TestLocatePlugin(t *testing.T) { - cwd, _ := os.Getwd() - _, err := locatePlugin(cwd) - if !assert.Error(t, err) { - t.Fail() - } - - _, err = locatePlugin("") - if !assert.Error(t, err) { - t.Fail() - } - - startPath := "examples/debugtalk.so" - _, err = locatePlugin(startPath) - if !assert.Nil(t, err) { - t.Fail() - } - - startPath = "examples/demo.json" - _, err = locatePlugin(startPath) - if !assert.Nil(t, err) { - t.Fail() - } - - startPath = "examples/" - _, err = locatePlugin(startPath) - if !assert.Nil(t, err) { - t.Fail() - } - - startPath = "examples/plugin/debugtalk.go" - _, err = locatePlugin(startPath) - if !assert.Nil(t, err) { - t.Fail() - } - - startPath = "/abc" - _, err = locatePlugin(startPath) - if !assert.Error(t, err) { - t.Fail() - } -} - func TestHttpRunner(t *testing.T) { testcase1 := &TestCase{ Config: NewConfig("TestCase1"). From 014e53cef4b3a313ea753da09bc0a871eb85207e Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 6 Jan 2022 18:01:23 +0800 Subject: [PATCH 6/8] refactor: call function --- parser.go | 125 +--------------------------------------------- parser_test.go | 14 +++--- plugin.go | 132 +++++++++++++++++++++++++++++++++++++++++++++++++ plugin_test.go | 10 ++-- 4 files changed, 142 insertions(+), 139 deletions(-) create mode 100644 plugin.go diff --git a/parser.go b/parser.go index 03dc0140..49062ae2 100644 --- a/parser.go +++ b/parser.go @@ -9,14 +9,11 @@ import ( "plugin" "reflect" "regexp" - "runtime" "strings" "github.com/maja42/goval" "github.com/pkg/errors" "github.com/rs/zerolog/log" - - "github.com/httprunner/hrp/internal/builtin" ) func newParser() *parser { @@ -28,40 +25,6 @@ type parser struct { pluginLoader *plugin.Plugin } -func (p *parser) loadPlugin(path string) error { - if runtime.GOOS == "windows" { - log.Warn().Msg("go plugin does not support windows") - return nil - } - - if path == "" { - return nil - } - - // check if loaded before - if p.pluginLoader != nil { - return nil - } - - // locate plugin file - pluginPath, err := locatePlugin(path) - if err != nil { - // plugin not found - return nil - } - - // load plugin - plugins, err := plugin.Open(pluginPath) - if err != nil { - log.Error().Err(err).Str("path", path).Msg("load go plugin failed") - return err - } - p.pluginLoader = plugins - - log.Info().Str("path", path).Msg("load go plugin success") - return nil -} - // locatePlugin searches debugtalk.so upward recursively until current // working directory or system root dir. func locatePlugin(startPath string) (string, error) { @@ -251,12 +214,7 @@ func (p *parser) parseString(raw string, variablesMapping map[string]interface{} return raw, err } - fn, err := getMappingFunction(funcName, p.pluginLoader) - if err != nil { - return raw, err - } - - result, err := callFunc(fn, parsedArgs.([]interface{})...) + result, err := p.callFunc(funcName, parsedArgs.([]interface{})...) if err != nil { log.Error().Str("funcName", funcName).Interface("arguments", arguments). Err(err).Msg("call function failed") @@ -342,87 +300,6 @@ func mergeVariables(variables, overriddenVariables map[string]interface{}) map[s return mergedVariables } -func getMappingFunction(funcName string, pluginLoader *plugin.Plugin) (reflect.Value, error) { - var fn reflect.Value - var err error - - defer func() { - // check function type - if err == nil && fn.Kind() != reflect.Func { - // function not valid - err = fmt.Errorf("function %s is invalid", funcName) - return - } - }() - - // get function from plugin loader - if pluginLoader != nil { - sym, err := pluginLoader.Lookup(funcName) - if err == nil { - fn = reflect.ValueOf(sym) - return fn, nil - } - } - - // get builtin function - if function, ok := builtin.Functions[funcName]; ok { - fn = reflect.ValueOf(function) - return fn, nil - } - - // function not found - return reflect.Value{}, fmt.Errorf("function %s is not found", funcName) -} - -// callFunc call function with arguments -// only support return at most one result value -func callFunc(fn reflect.Value, arguments ...interface{}) (interface{}, error) { - if fn.Type().NumIn() != len(arguments) { - // function arguments not match - return nil, fmt.Errorf("function arguments number not match") - } - - argumentsValue := make([]reflect.Value, len(arguments)) - for index, argument := range arguments { - argumentValue := reflect.ValueOf(argument) - expectArgumentType := fn.Type().In(index) - actualArgumentType := reflect.TypeOf(argument) - - // type match - if expectArgumentType == actualArgumentType { - argumentsValue[index] = argumentValue - continue - } - - // type not match, check if convertible - if !actualArgumentType.ConvertibleTo(expectArgumentType) { - // function argument type not match and not convertible - err := fmt.Errorf("function argument %d's type is neither match nor convertible, expect %v, actual %v", - index, expectArgumentType, actualArgumentType) - return nil, err - } - // convert argument to expect type - argumentsValue[index] = argumentValue.Convert(expectArgumentType) - } - - resultValues := fn.Call(argumentsValue) - if len(resultValues) > 1 { - // function should return at most one value - err := fmt.Errorf("function should return at most one value") - return nil, err - } - - // no return value - if len(resultValues) == 0 { - return nil, nil - } - - // return one value - // convert reflect.Value to interface{} - result := resultValues[0].Interface() - return result, nil -} - var eval = goval.NewEvaluator() // literalEval parse string to number if possible diff --git a/parser_test.go b/parser_test.go index 70ffe899..b79f1c2b 100644 --- a/parser_test.go +++ b/parser_test.go @@ -378,17 +378,17 @@ func TestMergeVariables(t *testing.T) { } func TestCallBuiltinFunction(t *testing.T) { + parser := newParser() + // call function without arguments - f1, _ := getMappingFunction("get_timestamp", nil) - _, err := callFunc(f1) + _, err := parser.callFunc("get_timestamp") if !assert.NoError(t, err) { t.Fail() } // call function with one argument timeStart := time.Now() - f2, _ := getMappingFunction("sleep", nil) - _, err = callFunc(f2, 1) + _, err = parser.callFunc("sleep", 1) if !assert.NoError(t, err) { t.Fail() } @@ -397,8 +397,7 @@ func TestCallBuiltinFunction(t *testing.T) { } // call function with one argument - f3, _ := getMappingFunction("gen_random_string", nil) - result, err := callFunc(f3, 10) + result, err := parser.callFunc("gen_random_string", 10) if !assert.NoError(t, err) { t.Fail() } @@ -407,8 +406,7 @@ func TestCallBuiltinFunction(t *testing.T) { } // call function with two argument - f4, _ := getMappingFunction("max", nil) - result, err = callFunc(f4, float64(10), 9.99) + result, err = parser.callFunc("max", float64(10), 9.99) if !assert.NoError(t, err) { t.Fail() } diff --git a/plugin.go b/plugin.go new file mode 100644 index 00000000..005dbda8 --- /dev/null +++ b/plugin.go @@ -0,0 +1,132 @@ +package hrp + +import ( + "fmt" + "plugin" + "reflect" + "runtime" + + "github.com/rs/zerolog/log" + + "github.com/httprunner/hrp/internal/builtin" +) + +func (p *parser) loadPlugin(path string) error { + if runtime.GOOS == "windows" { + log.Warn().Msg("go plugin does not support windows") + return nil + } + + if path == "" { + return nil + } + + // check if loaded before + if p.pluginLoader != nil { + return nil + } + + // locate plugin file + pluginPath, err := locatePlugin(path) + if err != nil { + // plugin not found + return nil + } + + // load plugin + plugins, err := plugin.Open(pluginPath) + if err != nil { + log.Error().Err(err).Str("path", path).Msg("load go plugin failed") + return err + } + p.pluginLoader = plugins + + log.Info().Str("path", path).Msg("load go plugin success") + return nil +} + +func getMappingFunction(funcName string, pluginLoader *plugin.Plugin) (reflect.Value, error) { + var fn reflect.Value + var err error + + defer func() { + // check function type + if err == nil && fn.Kind() != reflect.Func { + // function not valid + err = fmt.Errorf("function %s is invalid", funcName) + return + } + }() + + // get function from plugin loader + if pluginLoader != nil { + sym, err := pluginLoader.Lookup(funcName) + if err == nil { + fn = reflect.ValueOf(sym) + return fn, nil + } + } + + // get builtin function + if function, ok := builtin.Functions[funcName]; ok { + fn = reflect.ValueOf(function) + return fn, nil + } + + // function not found + return reflect.Value{}, fmt.Errorf("function %s is not found", funcName) +} + +// callFunc calls function with arguments +// only support return at most one result value +func (p *parser) callFunc(funcName string, arguments ...interface{}) (interface{}, error) { + fn, err := getMappingFunction(funcName, p.pluginLoader) + if err != nil { + return nil, err + } + + if fn.Type().NumIn() != len(arguments) { + // function arguments not match + return nil, fmt.Errorf("function arguments number not match") + } + + argumentsValue := make([]reflect.Value, len(arguments)) + for index, argument := range arguments { + argumentValue := reflect.ValueOf(argument) + expectArgumentType := fn.Type().In(index) + actualArgumentType := reflect.TypeOf(argument) + + // type match + if expectArgumentType == actualArgumentType { + argumentsValue[index] = argumentValue + continue + } + + // type not match, check if convertible + if !actualArgumentType.ConvertibleTo(expectArgumentType) { + // function argument type not match and not convertible + err := fmt.Errorf("function argument %d's type is neither match nor convertible, expect %v, actual %v", + index, expectArgumentType, actualArgumentType) + return nil, err + } + // convert argument to expect type + argumentsValue[index] = argumentValue.Convert(expectArgumentType) + } + + resultValues := fn.Call(argumentsValue) + if len(resultValues) > 1 { + // function should return at most one value + err := fmt.Errorf("function should return at most one value") + return nil, err + } + + // no return value + if len(resultValues) == 0 { + return nil, nil + } + + // return one value + // convert reflect.Value to interface{} + result := resultValues[0].Interface() + return result, nil +} diff --git a/plugin_test.go b/plugin_test.go index 75fc271d..7e4ff8ac 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -7,7 +7,6 @@ import ( "fmt" "os" "os/exec" - "plugin" "testing" "github.com/stretchr/testify/assert" @@ -27,14 +26,11 @@ func TestMain(m *testing.M) { } func TestCallPluginFunction(t *testing.T) { - pluginLoader, err := plugin.Open("examples/debugtalk.so") - if err != nil { - t.Fatalf(err.Error()) - } + parser := newParser() + parser.loadPlugin("examples/debugtalk.so") // call function without arguments - f1, _ := getMappingFunction("Concatenate", pluginLoader) - result, err := callFunc(f1, 1, "2", 3.14) + result, err := parser.callFunc("Concatenate", 1, "2", 3.14) if !assert.NoError(t, err) { t.Fail() } From c7ed6db537cf1c555c6daca4684d91ed7540ba23 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 6 Jan 2022 18:22:25 +0800 Subject: [PATCH 7/8] fix: unittest --- plugin_test.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/plugin_test.go b/plugin_test.go index 7e4ff8ac..41e37684 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -14,11 +14,7 @@ import ( func TestMain(m *testing.M) { fmt.Println("[TestMain] build go plugin") - // go tool compile -help - // all=: apply to all packages - // -N: disable optimizations - // -l: disable inlining - cmd := exec.Command("go", "build", "-buildmode=plugin", `-gcflags="all=-N -l"`, "-o=examples/debugtalk.so", "examples/plugin/debugtalk.go") + cmd := exec.Command("go", "build", "-buildmode=plugin", `-race`, "-o=examples/debugtalk.so", "examples/plugin/debugtalk.go") if err := cmd.Run(); err != nil { panic(err) } From 7148328208ae12868ae7433de74caf855c29af21 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Thu, 6 Jan 2022 18:40:00 +0800 Subject: [PATCH 8/8] fix: avoid data racing --- parser_test.go | 44 -------------------------------------------- plugin_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ runner.go | 10 +++++++++- 3 files changed, 53 insertions(+), 45 deletions(-) diff --git a/parser_test.go b/parser_test.go index b79f1c2b..dbf75af9 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,7 +1,6 @@ package hrp import ( - "os" "sort" "testing" "time" @@ -9,49 +8,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestLocatePlugin(t *testing.T) { - cwd, _ := os.Getwd() - _, err := locatePlugin(cwd) - if !assert.Error(t, err) { - t.Fail() - } - - _, err = locatePlugin("") - if !assert.Error(t, err) { - t.Fail() - } - - startPath := "examples/debugtalk.so" - _, err = locatePlugin(startPath) - if !assert.Nil(t, err) { - t.Fail() - } - - startPath = "examples/demo.json" - _, err = locatePlugin(startPath) - if !assert.Nil(t, err) { - t.Fail() - } - - startPath = "examples/" - _, err = locatePlugin(startPath) - if !assert.Nil(t, err) { - t.Fail() - } - - startPath = "examples/plugin/debugtalk.go" - _, err = locatePlugin(startPath) - if !assert.Nil(t, err) { - t.Fail() - } - - startPath = "/abc" - _, err = locatePlugin(startPath) - if !assert.Error(t, err) { - t.Fail() - } -} - func TestBuildURL(t *testing.T) { var url string url = buildURL("https://postman-echo.com", "/get") diff --git a/plugin_test.go b/plugin_test.go index 41e37684..b811c6e3 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -14,6 +14,7 @@ import ( func TestMain(m *testing.M) { fmt.Println("[TestMain] build go plugin") + // flag -race is necessary in order to be consistent with go test cmd := exec.Command("go", "build", "-buildmode=plugin", `-race`, "-o=examples/debugtalk.so", "examples/plugin/debugtalk.go") if err := cmd.Run(); err != nil { panic(err) @@ -21,6 +22,49 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } +func TestLocatePlugin(t *testing.T) { + cwd, _ := os.Getwd() + _, err := locatePlugin(cwd) + if !assert.Error(t, err) { + t.Fail() + } + + _, err = locatePlugin("") + if !assert.Error(t, err) { + t.Fail() + } + + startPath := "examples/debugtalk.so" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "examples/demo.json" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "examples/" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "examples/plugin/debugtalk.go" + _, err = locatePlugin(startPath) + if !assert.Nil(t, err) { + t.Fail() + } + + startPath = "/abc" + _, err = locatePlugin(startPath) + if !assert.Error(t, err) { + t.Fail() + } +} + func TestCallPluginFunction(t *testing.T) { parser := newParser() parser.loadPlugin("examples/debugtalk.so") diff --git a/runner.go b/runner.go index 644d2c5e..c59e1fec 100644 --- a/runner.go +++ b/runner.go @@ -477,8 +477,16 @@ func (r *caseRunner) runStepTestCase(step *TStep) (stepResult *stepData, err err success: false, } testcase := step.TestCase + + // copy testcase to avoid data racing + copiedTestCase := &TestCase{} + if err = copier.Copy(copiedTestCase, testcase); err != nil { + log.Error().Err(err).Msg("copy testcase failed") + return nil, err + } + start := time.Now() - err = r.hrpRunner.newCaseRunner(testcase).run() + err = r.hrpRunner.newCaseRunner(copiedTestCase).run() stepResult.elapsed = time.Since(start).Milliseconds() if err != nil { return stepResult, err