diff --git a/samples/http-client.gb b/samples/http-client.gb new file mode 100644 index 000000000..bca2ce2d0 --- /dev/null +++ b/samples/http-client.gb @@ -0,0 +1,10 @@ +require "net/http" + +c = Net::HTTP::Client.new + +res = c.send do |req| + req.url = "https://google.com" + req.method = "GET" +end + +puts res.body \ No newline at end of file diff --git a/vm/error.go b/vm/error.go index 06546bbb5..451d24ec8 100644 --- a/vm/error.go +++ b/vm/error.go @@ -53,7 +53,7 @@ func (vm *VM) initErrorObject(errorType, format string, args ...interface{}) *Er } func (vm *VM) initErrorClasses() { - errTypes := []string{errors.InternalError, errors.ArgumentError, errors.NameError, errors.TypeError, errors.UndefinedMethodError, errors.UnsupportedMethodError, errors.ConstantAlreadyInitializedError} + errTypes := []string{errors.InternalError, errors.ArgumentError, errors.NameError, errors.TypeError, errors.UndefinedMethodError, errors.UnsupportedMethodError, errors.ConstantAlreadyInitializedError, errors.HTTPError} for _, errType := range errTypes { c := vm.initializeClass(errType, false) diff --git a/vm/errors/error.go b/vm/errors/error.go index 53980ad3e..ef8cc5838 100644 --- a/vm/errors/error.go +++ b/vm/errors/error.go @@ -15,6 +15,8 @@ const ( UnsupportedMethodError = "UnsupportedMethodError" // ConstantAlreadyInitializedError means user re-declares twice ConstantAlreadyInitializedError = "ConstantAlreadyInitializedError" + // HTTPError is returned when when a request fails to return a proper response + HTTPError = "HTTPError" ) /* diff --git a/vm/http.go b/vm/http.go index 9a0d3a465..2809820a4 100644 --- a/vm/http.go +++ b/vm/http.go @@ -14,36 +14,44 @@ import ( var ( httpRequestClass *RClass httpResponseClass *RClass + httpClientClass *RClass ) // Class methods -------------------------------------------------------- func builtinHTTPClassMethods() []*BuiltinMethodObject { return []*BuiltinMethodObject{ { - // Sends a GET request to the target and returns the HTTP response as a string. + // Sends a GET request to the target and returns the HTTP response as a string. Will error on non-200 responses, for more control over http requests look at the `start` method. Name: "get", Fn: func(receiver Object) builtinMethodBody { return func(t *thread, args []Object, blockFrame *callFrame) Object { + arg0, ok := args[0].(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.ArgumentError, "Expect argument 0 to be string, got: %s", args[0].Class().Name) + } - uri, err := url.Parse(args[0].(*StringObject).value) + uri, err := url.Parse(arg0.value) if len(args) > 1 { var arr []string - for _, v := range args[1:] { - arr = append(arr, v.(*StringObject).value) + for i, v := range args[1:] { + argn, ok := v.(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.ArgumentError, "Splat arguments must be a string, got: %s for argument %d", v.Class().Name, i) + } + arr = append(arr, argn.value) } uri.Path = path.Join(arr...) } resp, err := http.Get(uri.String()) - if err != nil { - return t.vm.initErrorObject(errors.InternalError, err.Error()) + return t.vm.initErrorObject(errors.HTTPError, "Could not complete request, %s", err) } if resp.StatusCode != http.StatusOK { - return t.vm.initErrorObject(errors.InternalError, resp.Status) + return t.vm.initErrorObject(errors.HTTPError, "Non-200 response, %s (%d)", resp.Status, resp.StatusCode) } content, err := ioutil.ReadAll(resp.Body) @@ -57,30 +65,38 @@ func builtinHTTPClassMethods() []*BuiltinMethodObject { } }, }, { - // Sends a POST request to the target with type header and body. Returns the HTTP response as a string. + // Sends a POST request to the target with type header and body. Returns the HTTP response as a string. Will error on non-200 responses, for more control over http requests look at the `start` method. Name: "post", Fn: func(receiver Object) builtinMethodBody { return func(t *thread, args []Object, blockFrame *callFrame) Object { if len(args) != 3 { - return t.vm.initErrorObject(errors.ArgumentError, "Expect 3 arguments. got=%v", strconv.Itoa(len(args))) + return t.vm.initErrorObject(errors.ArgumentError, errors.WrongNumberOfArgumentFormat, 3, len(args)) } - uri, err := url.Parse(args[0].(*StringObject).value) - if err != nil { - return t.vm.initErrorObject(errors.ArgumentError, err.Error()) + arg0, ok := args[0].(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.ArgumentError, "Expect argument 0 to be string, got: %s", args[0].Class().Name) } + host := arg0.value - contentType := args[1].(*StringObject).value - - body := args[2].(*StringObject).value + arg1, ok := args[1].(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.ArgumentError, "Expect argument 1 to be string, got: %s", args[0].Class().Name) + } + contentType := arg1.value - resp, err := http.Post(uri.String(), contentType, strings.NewReader(body)) + arg2, ok := args[2].(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.ArgumentError, "Expect argument 2 to be string, got: %s", args[0].Class().Name) + } + body := arg2.value + resp, err := http.Post(host, contentType, strings.NewReader(body)) if err != nil { - return t.vm.initErrorObject(errors.InternalError, err.Error()) + return t.vm.initErrorObject(errors.HTTPError, "Could not complete request, %s", err) } if resp.StatusCode != http.StatusOK { - return t.vm.initErrorObject(errors.InternalError, resp.Status) + return t.vm.initErrorObject(errors.HTTPError, "Non-200 response, %s (%d)", resp.Status, resp.StatusCode) } content, err := ioutil.ReadAll(resp.Body) @@ -93,6 +109,70 @@ func builtinHTTPClassMethods() []*BuiltinMethodObject { return t.vm.initStringObject(string(content)) } }, + }, { + // Sends a HEAD request to the target with type header and body. Returns the HTTP headers as a map[string]string. Will error on non-200 responses, for more control over http requests look at the `start` method. + Name: "head", + Fn: func(receiver Object) builtinMethodBody { + return func(t *thread, args []Object, blockFrame *callFrame) Object { + arg0, ok := args[0].(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.ArgumentError, "Expect argument 0 to be string, got: %s", args[0].Class().Name) + } + + uri, err := url.Parse(arg0.value) + + if len(args) > 1 { + var arr []string + + for i, v := range args[1:] { + argn, ok := v.(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.ArgumentError, "Splat arguments must be a string, got: %s for argument %d", v.Class().Name, i) + } + arr = append(arr, argn.value) + } + + uri.Path = path.Join(arr...) + } + + resp, err := http.Head(uri.String()) + if err != nil { + return t.vm.initErrorObject(errors.HTTPError, "Could not complete request, %s", err) + } + if resp.StatusCode != http.StatusOK { + return t.vm.initErrorObject(errors.HTTPError, "Non-200 response, %s (%d)", resp.Status, resp.StatusCode) + } + + ret := t.vm.initHashObject(map[string]Object{}) + + for k, v := range resp.Header { + ret.Pairs[k] = t.vm.initStringObject(strings.Join(v, " ")) + } + + return ret + } + }, + }, { + // Starts an HTTP client. This method requires a block which takes a Net::HTTP::Client object. The return value of this method is the last evaluated value of the provided block. + Name: "start", + Fn: func(receiver Object) builtinMethodBody { + return func(t *thread, args []Object, blockFrame *callFrame) Object { + + if len(args) != 0 { + return t.vm.initErrorObject(errors.ArgumentError, "Expect 0 arguments. got=%v", strconv.Itoa(len(args))) + } + + gobyClient := httpClientClass.initializeInstance() + + result := t.builtinMethodYield(blockFrame, gobyClient) + + if err, ok := result.Target.(*Error); ok { + return err //an Error object + } + + return result.Target + } + }, }, } } @@ -107,6 +187,7 @@ func initHTTPClass(vm *VM) { http.setBuiltinMethods(builtinHTTPClassMethods(), true) initRequestClass(vm, http) initResponseClass(vm, http) + initClientClass(vm, http) net.setClassConstant(http) diff --git a/vm/http_client.go b/vm/http_client.go new file mode 100644 index 000000000..0a903efbb --- /dev/null +++ b/vm/http_client.go @@ -0,0 +1,229 @@ +package vm + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" + + "github.com/goby-lang/goby/vm/classes" + "github.com/goby-lang/goby/vm/errors" +) + +// Instance methods -------------------------------------------------------- + +func builtinHTTPClientInstanceMethods() []*BuiltinMethodObject { + //TODO: cookie jar and mutable client + goClient := http.DefaultClient + + return []*BuiltinMethodObject{ + { + // Sends a GET request to the target and returns a `Net::HTTP::Response` object. + Name: "get", + Fn: func(receiver Object) builtinMethodBody { + return func(t *thread, args []Object, blockFrame *callFrame) Object { + if len(args) != 1 { + return t.vm.initErrorObject(errors.ArgumentError, errors.WrongNumberOfArgumentFormat, 1, len(args)) + } + + u, ok := args[0].(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.TypeError, errors.WrongArgumentTypeFormat, classes.StringClass, u.Class().Name) + } + + resp, err := goClient.Get(u.value) + if err != nil { + return t.vm.initErrorObject(errors.HTTPError, "Could not complete request, %s", err) + } + + gobyResp, err := responseGoToGoby(t, resp) + if err != nil { + return t.vm.initErrorObject(errors.InternalError, err.Error()) + } + + return gobyResp + } + }, + }, { + // Sends a POST request to the target and returns a `Net::HTTP::Response` object. + Name: "post", + Fn: func(receiver Object) builtinMethodBody { + return func(t *thread, args []Object, blockFrame *callFrame) Object { + if len(args) != 3 { + return t.vm.initErrorObject(errors.ArgumentError, errors.WrongNumberOfArgumentFormat, 3, len(args)) + } + + u, ok := args[0].(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.TypeError, errors.WrongArgumentTypeFormat, classes.StringClass, u.Class().Name) + } + + contentType, ok := args[1].(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.TypeError, errors.WrongArgumentTypeFormat, classes.StringClass, u.Class().Name) + } + + body, ok := args[2].(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.TypeError, errors.WrongArgumentTypeFormat, classes.StringClass, u.Class().Name) + } + + bodyR := strings.NewReader(body.value) + + resp, err := goClient.Post(u.value, contentType.value, bodyR) + if err != nil { + return t.vm.initErrorObject(errors.HTTPError, "Could not complete request, %s", err) + } + + gobyResp, err := responseGoToGoby(t, resp) + if err != nil { + return t.vm.initErrorObject(errors.InternalError, err.Error()) + } + + return gobyResp + } + }, + }, { + // Sends a HEAD request to the target and returns a `Net::HTTP::Response` object. + Name: "head", + Fn: func(receiver Object) builtinMethodBody { + return func(t *thread, args []Object, blockFrame *callFrame) Object { + if len(args) != 1 { + return t.vm.initErrorObject(errors.ArgumentError, errors.WrongNumberOfArgumentFormat, 1, len(args)) + } + + u, ok := args[0].(*StringObject) + if !ok { + return t.vm.initErrorObject(errors.TypeError, errors.WrongArgumentTypeFormat, classes.StringClass, u.Class().Name) + } + + resp, err := goClient.Head(u.value) + if err != nil { + return t.vm.initErrorObject(errors.HTTPError, "Could not complete request, %s", err) + } + + gobyResp, err := responseGoToGoby(t, resp) + if err != nil { + return t.vm.initErrorObject(errors.InternalError, err.Error()) + } + + return gobyResp + } + }, + }, { + // Returns a blank `Net::HTTP::Request` object to be sent with the`exec` method + Name: "request", + Fn: func(receiver Object) builtinMethodBody { + return func(t *thread, args []Object, blockFrame *callFrame) Object { + return httpRequestClass.initializeInstance() + } + }, + }, { + // Sends a passed `Net::HTTP::Request` object and returns a `Net::HTTP::Response` object + Name: "exec", + Fn: func(receiver Object) builtinMethodBody { + return func(t *thread, args []Object, blockFrame *callFrame) Object { + if len(args) != 1 { + return t.vm.initErrorObject(errors.ArgumentError, errors.WrongNumberOfArgumentFormat, 1, len(args)) + } + + if args[0].Class().Name != httpRequestClass.Name { + return t.vm.initErrorObject(errors.TypeError, errors.WrongArgumentTypeFormat, "HTTP Response", args[0].Class().Name) + } + + goReq, err := requestGobyToGo(args[0]) + if err != nil { + return t.vm.initErrorObject(errors.ArgumentError, err.Error()) + } + + goResp, err := goClient.Do(goReq) + if err != nil { + return t.vm.initErrorObject(errors.HTTPError, "Could not complete request, %s", err) + } + + gobyResp, err := responseGoToGoby(t, goResp) + + if err != nil { + return t.vm.initErrorObject(errors.InternalError, err.Error()) + } + + return gobyResp + } + }, + }, + } +} + +// Internal functions =================================================== + +// Functions for initialization ----------------------------------------- + +func initClientClass(vm *VM, hc *RClass) *RClass { + clientClass := vm.initializeClass("Client", false) + hc.setClassConstant(clientClass) + + clientClass.setBuiltinMethods(builtinHTTPClientInstanceMethods(), false) + + httpClientClass = clientClass + return clientClass +} + +// Other helper functions ----------------------------------------------- + +func requestGobyToGo(gobyReq Object) (*http.Request, error) { + //:method, :protocol, :body, :content_length, :transfer_encoding, :host, :path, :url, :params + uObj, ok := gobyReq.instanceVariableGet("@url") + if !ok { + return nil, fmt.Errorf("could not get url") + } + + u := uObj.(*StringObject).value + + methodObj, ok := gobyReq.instanceVariableGet("@method") + if !ok { + return nil, fmt.Errorf("could not get method") + } + + method := methodObj.(*StringObject).value + + var body string + if !(method == "GET" || method == "HEAD") { + bodyObj, ok := gobyReq.instanceVariableGet("@body") + if !ok { + return nil, fmt.Errorf("could not get body") + } + + body = bodyObj.(*StringObject).value + } + + return http.NewRequest(method, u, strings.NewReader(body)) + +} + +func responseGoToGoby(t *thread, goResp *http.Response) (Object, error) { + gobyResp := httpResponseClass.initializeInstance() + + //attr_accessor :body, :status, :status_code, :protocol, :transfer_encoding, :http_version, :request_http_version, :request + //attr_reader :headers + + body, err := ioutil.ReadAll(goResp.Body) + if err != nil { + return nil, err + } + + gobyResp.instanceVariableSet("@body", t.vm.initStringObject(string(body))) + gobyResp.instanceVariableSet("@status_code", t.vm.initObjectFromGoType(goResp.StatusCode)) + gobyResp.instanceVariableSet("@status", t.vm.initObjectFromGoType(goResp.Status)) + gobyResp.instanceVariableSet("@protocol", t.vm.initObjectFromGoType(goResp.Proto)) + gobyResp.instanceVariableSet("@transfer_encoding", t.vm.initObjectFromGoType(goResp.TransferEncoding)) + + underHeaders := map[string]Object{} + + for k, v := range goResp.Header { + underHeaders[k] = t.vm.initObjectFromGoType(v) + } + + gobyResp.instanceVariableSet("@headers", t.vm.initHashObject(underHeaders)) + + return gobyResp, nil +} diff --git a/vm/http_client_test.go b/vm/http_client_test.go new file mode 100644 index 000000000..3ef508688 --- /dev/null +++ b/vm/http_client_test.go @@ -0,0 +1,111 @@ +package vm + +import "testing" + +func TestHTTPClientObject(t *testing.T) { + + //blocking channel + c := make(chan bool, 1) + + //server to test off of + go startTestServer(c) + + tests := []struct { + input string + expected interface{} + }{ + //test get request + {` + require "net/http" + + res = Net::HTTP.start do |client| + res = client.get("http://127.0.0.1:3000/index") + end + + res.body + `, "GET Hello World"}, + {` + require "net/http" + + res = Net::HTTP.start do |client| + client.post("http://127.0.0.1:3000/index", "text/plain", "Hi Again") + end + + res.body + `, "POST Hi Again"}, + {` + require "net/http" + + res = Net::HTTP.start do |client| + r = client.request() + r.url = "http://127.0.0.1:3000/index" + r.method = "POST" + r.body = "Another way of doing it" + client.exec(r) + end + + res.body + `, "POST Another way of doing it"}, + {` + require "net/http" + + res = Net::HTTP.start do |client| + client.head("http://127.0.0.1:3000/index") + end + + res.status_code + `, 200}, + {` + require "net/http" + + res = Net::HTTP.start do |client| + client.get("http://127.0.0.1:3000/error") + end + + res.status_code + `, 404}, + } + + //block until server is ready + <-c + + for i, tt := range tests { + v := initTestVM() + evaluated := v.testEval(t, tt.input, getFilename()) + checkExpected(t, i, evaluated, tt.expected) + v.checkCFP(t, i, 0) + v.checkSP(t, i, 1) + } +} + +func TestHTTPClientObjectFail(t *testing.T) { + + testsFail := []errorTestCase{ + {` + require "net/http" + + res = Net::HTTP.start do |client| + client.get("http://127.0.0.1:3001") + end + + res + `, "HTTPError: Could not complete request, Get http://127.0.0.1:3001: dial tcp 127.0.0.1:3001: getsockopt: connection refused", 5}, + {` + require "net/http" + + res = Net::HTTP.start do |client| + client.get("http://127.0.0.1:3001") + end + + res + `, "HTTPError: Could not complete request, Get http://127.0.0.1:3001: dial tcp 127.0.0.1:3001: getsockopt: connection refused", 5}, + } + + for i, tt := range testsFail { + v := initTestVM() + evaluated := v.testEval(t, tt.input, getFilename()) + checkError(t, i, evaluated, tt.expected, getFilename(), tt.errorLine) + v.checkCFP(t, i, 3) + v.checkSP(t, i, 1) + } +} diff --git a/vm/http_test.go b/vm/http_test.go index 6cdab8ac0..46938e8d8 100644 --- a/vm/http_test.go +++ b/vm/http_test.go @@ -65,6 +65,12 @@ func TestHTTPObject(t *testing.T) { Net::HTTP.post("http://127.0.0.1:3000/index", "text/plain", "Hi Again") `, "POST Hi Again"}, + {` + require "net/http" + + res = Net::HTTP.head("http://127.0.0.1:3000/index") + res["Content-Length"] + `, "15"}, } //block until server is ready @@ -87,21 +93,72 @@ func TestHTTPObjectFail(t *testing.T) { go startTestServer(c) testsFail := []errorTestCase{ + //HTTPErrors for get() {` require "net/http" Net::HTTP.get("http://127.0.0.1:3000/error") - `, "InternalError: 404 Not Found", 4}, + `, "HTTPError: Non-200 response, 404 Not Found (404)", 4}, + {` + require "net/http" + + Net::HTTP.get("http://127.0.0.1:3001") + `, "HTTPError: Could not complete request, Get http://127.0.0.1:3001: dial tcp 127.0.0.1:3001: getsockopt: connection refused", 4}, + //Argument errors for get() + {` + require "net/http" + + Net::HTTP.get(42) + `, "ArgumentError: Expect argument 0 to be string, got: Integer", 4}, + {` + require "net/http" + + Net::HTTP.get("http://127.0.0.1:3000/error", 40, 2) + `, "ArgumentError: Splat arguments must be a string, got: Integer for argument 0", 4}, + //HTTPErrors for post() {` require "net/http" Net::HTTP.post("http://127.0.0.1:3000/error", "text/plain", "Let me down") - `, "InternalError: 404 Not Found", 4}, + `, "HTTPError: Non-200 response, 404 Not Found (404)", 4}, {` require "net/http" Net::HTTP.post("http://127.0.0.1:3001", "text/plain", "Let me down") - `, "InternalError: Post http://127.0.0.1:3001: dial tcp 127.0.0.1:3001: getsockopt: connection refused", 4}, + `, "HTTPError: Could not complete request, Post http://127.0.0.1:3001: dial tcp 127.0.0.1:3001: getsockopt: connection refused", 4}, + //Argument errors for post() + {` + require "net/http" + + Net::HTTP.post("http://127.0.0.1:3001", "text/plain", "Let me down", "again") + `, "ArgumentError: Expect 3 arguments. got: 4", 4}, + {` + require "net/http" + + Net::HTTP.post(42, "text/plain", "Let me down") + `, "ArgumentError: Expect argument 0 to be string, got: Integer", 4}, + //HTTPErrors for head() + {` + require "net/http" + + Net::HTTP.head("http://127.0.0.1:3000/error") + `, "HTTPError: Non-200 response, 404 Not Found (404)", 4}, + {` + require "net/http" + + Net::HTTP.head("http://127.0.0.1:3001") + `, "HTTPError: Could not complete request, Head http://127.0.0.1:3001: dial tcp 127.0.0.1:3001: getsockopt: connection refused", 4}, + //Argument errors for head() + {` + require "net/http" + + Net::HTTP.head(42) + `, "ArgumentError: Expect argument 0 to be string, got: Integer", 4}, + {` + require "net/http" + + Net::HTTP.head("http://127.0.0.1:3000/error", 40, 2) + `, "ArgumentError: Splat arguments must be a string, got: Integer for argument 0", 4}, } //block until server is ready