diff --git a/go.mod b/go.mod index 732ff142..772d3f94 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/d5/tengo/v2 -go 1.13 +go 1.16 diff --git a/stdlib/builtin_modules.go b/stdlib/builtin_modules.go index cf0e9621..2fa00e62 100644 --- a/stdlib/builtin_modules.go +++ b/stdlib/builtin_modules.go @@ -15,4 +15,5 @@ var BuiltinModules = map[string]map[string]tengo.Object{ "json": jsonModule, "base64": base64Module, "hex": hexModule, + "http": httpModule, } diff --git a/stdlib/http.go b/stdlib/http.go new file mode 100644 index 00000000..dff92238 --- /dev/null +++ b/stdlib/http.go @@ -0,0 +1,115 @@ +package stdlib + +import ( + "bytes" + "io" + "net/http" + + "github.com/d5/tengo/v2" +) + +var httpModule = map[string]tengo.Object{ + "do": &tengo.UserFunction{ + Name: "do", + Value: (func(args ...tengo.Object) (tengo.Object, error) { + numArgs := len(args) + if numArgs < 2 || numArgs > 4 { + return nil, tengo.ErrWrongNumArguments + } + + // build req from method, url [, headers[, body]] + method, ok := args[0].(*tengo.String) + if !ok { + return nil, tengo.ErrInvalidArgumentType{ + Name: "1/method", + Expected: "string", + Found: args[0].TypeName(), + } + } + + url, ok := args[1].(*tengo.String) + if !ok { + return nil, tengo.ErrInvalidArgumentType{ + Name: "2/url", + Expected: "string", + Found: args[1].TypeName(), + } + } + var body io.Reader + if len(args) > 3 { + bs, ok := args[3].(*tengo.Bytes) + if !ok { + return nil, tengo.ErrInvalidArgumentType{ + Name: "4/body", + Expected: "bytes", + Found: args[3].TypeName(), + } + } + body = bytes.NewBuffer(bs.Value) + } + req, err := http.NewRequest(method.Value, url.Value, body) + if err != nil { + return wrapError(err), nil + } + // add headers + if len(args) > 2 { + m, ok := args[2].(*tengo.Map) + if !ok { + return nil, tengo.ErrInvalidArgumentType{ + Name: "3/headers", + Expected: "map", + Found: args[2].TypeName(), + } + } + for k, v := range m.Value { + s, ok := tengo.ToString(v) + if !ok { + return nil, tengo.ErrInvalidArgumentType{ + Name: "headers", + Expected: "string", + Found: v.TypeName(), + } + } + if err != nil { + return nil, err + } + req.Header.Add(k, s) + } + } + + // do req + res, err := http.DefaultClient.Do(req) + if res != nil && res.Body != nil { + // ensure to always close body no matter what + defer res.Body.Close() + } + if err != nil { + return wrapError(err), nil + } + if res.ContentLength > int64(tengo.MaxBytesLen) { + // don't allow going over byte limit + return nil, tengo.ErrBytesLimit + } + + // read full body, with byte limit on it + bs, err := io.ReadAll(io.LimitReader(res.Body, int64(tengo.MaxBytesLen))) + if err != nil { + return wrapError(err), nil + } + resHeaders := &tengo.Map{Value: map[string]tengo.Object{}} + for k := range res.Header { + resHeaders.Value[k] = &tengo.String{Value: res.Header.Get(k)} + } + return &tengo.Map{ + Value: map[string]tengo.Object{ + "code": &tengo.Int{Value: int64(res.StatusCode)}, + "status": &tengo.String{Value: res.Status}, + "headers": resHeaders, + "body": &tengo.Bytes{ + Value: bs, + }, + }, + }, nil + }), + }, +} diff --git a/stdlib/http_test.go b/stdlib/http_test.go new file mode 100644 index 00000000..770634ed --- /dev/null +++ b/stdlib/http_test.go @@ -0,0 +1,53 @@ +package stdlib_test + +import ( + "fmt" + "testing" + + "github.com/d5/tengo/v2" + "github.com/d5/tengo/v2/stdlib" +) + +func TestHTTP(t *testing.T) { + run := func(required string, opts string, errMsg string) { + script := tengo.NewScript([]byte(fmt.Sprintf(` +http := import("http") +res := http.do(%s%s) +`, required, opts))) + script.SetImports(stdlib.GetModuleMap("http")) + + executed, err := script.Run() + if err != nil { + t.Error(err) + } + + res := executed.Get("res").Value() + + err, ok := res.(error) + if ok { + if err.Error() != errMsg { + t.Errorf("unexpected error: %s", err.Error()) + } + return + } + if !ok && errMsg != "" { + t.Errorf("missing expected error") + } + + check := func(name string, ok func(i interface{}) bool) { + if !ok(res.(map[string]interface{})[name]) { + t.Errorf("unexpected %s value", name) + } + } + + check("code", func(i interface{}) bool { v, ok := i.(int64); return ok && v == 200 }) + check("status", func(i interface{}) bool { v, ok := i.(string); return ok && v == "200 OK" }) + check("headers", func(i interface{}) bool { v, ok := i.(map[string]interface{}); return ok && len(v) > 0 }) + check("body", func(i interface{}) bool { v, ok := i.([]byte); return ok && len(v) > 0 }) + } + + required := `"GET", "https://avatars.githubusercontent.com/u/1291934?s=48&v=4"` + run(required, `, {dnt: 1, "my-header": "yolo"}, bytes("test")`, ``) + run(required, ``, ``) + run(`"GET", "tengo"`, ``, `error: "Get \"tengo\": unsupported protocol scheme \"\""`) +}