Skip to content

Commit

Permalink
Closes #20 - Implement an HTTP package
Browse files Browse the repository at this point in the history
The main request function is implemented in Go
with convenience functions written in Nitrogen.
  • Loading branch information
lfkeitel committed Jul 5, 2018
1 parent e698c84 commit 6e8e259
Show file tree
Hide file tree
Showing 6 changed files with 319 additions and 0 deletions.
75 changes: 75 additions & 0 deletions docs/stdlib/imported/http.ni.md
@@ -0,0 +1,75 @@
# http.ni

Functions for encoding and decoding http.

To use: `import 'stdlib/http'`

## get(url: string): Response

`get` makes an HTTP GET request to the given URL.

## post(url: string[, data: T, options: HTTPOptions]): Response

`post` makes an HTTP POST request to the given URL. If data is not a string,
it will be JSON encoded and the header `Content-Type` will be set to "json/application".

## getJSON(url: string): T

Calls `get` with the given URL and returns the output of `json.decode` on the
returned body.

## req(method: string, url: string[, data: string, options: HTTPOptions]): Response

`req` is a low-level command to the native HTTP implementation. `req` can be used
to send requests that aren't possible with the other convenience functions such
as other methods like DELETE or PUT.

## canonicalHeaderKey(s: string): string

`canonicalHeaderKey` returns the canonical format of the header key s. The
canonicalization converts the first letter and any letter following a hyphen to
upper case; the rest are converted to lowercase. For example, the canonical key
for "accept-encoding" is "Accept-Encoding". If s contains a space or invalid header
field bytes, it is returned without modifications.*


\* Description for `canonicalHeaderKey` taken from https://golang.org/pkg/net/http/#CanonicalHeaderKey.

## Response: map

Response is a map with the following structure:

```
{
"body": string
"headers": map
}
```

### Fields

#### body

The body of the returned request. No processing is done once received.

#### headers

`headers` is a map of string keys to string values containing the HTTP headers
sent or received. When received, the map keys will be in canonical header format.

## HTTPOptions: map

HTTPOptions is a map with the following structure:

```
{
"headers": map
}
```

### Fields

#### headers

`headers` is a map of string keys to string values containing the HTTP headers
sent or received. When received, the map keys will be in canonical header format.
40 changes: 40 additions & 0 deletions nitrogen/stdlib/http.ni
@@ -0,0 +1,40 @@
import "stdlib/native/http"
import "stdlib/json"

use http.do

const exports = {
"req": do,
"canonicalHeaderKey": http.canonicalHeaderKey,
}

func getJSON(url) {
const resp = get(url)
return json.decode(resp.body)
}
exports.getJSON = getJSON

func get(url) {
let options
if len(arguments) >= 1: options = arguments[0]
return do("GET", url, "", options)
}
exports.get = get

func post(url) {
let data
let options = {}

if len(arguments) >= 1: data = arguments[0]
if len(arguments) >= 2: options = arguments[1]

if !isNull(data) and !isString(data) {
data = json.encode(data)
options["Content-Type"] = "application/json"
}

return do("POST", url, data, options)
}
exports.post = post

return exports
1 change: 1 addition & 0 deletions src/builtins/builtins.go
Expand Up @@ -6,6 +6,7 @@ import (
_ "github.com/nitrogen-lang/nitrogen/src/builtins/classes"
_ "github.com/nitrogen-lang/nitrogen/src/builtins/collections"
_ "github.com/nitrogen-lang/nitrogen/src/builtins/dis"
_ "github.com/nitrogen-lang/nitrogen/src/builtins/http"
_ "github.com/nitrogen-lang/nitrogen/src/builtins/imports"
_ "github.com/nitrogen-lang/nitrogen/src/builtins/io"
_ "github.com/nitrogen-lang/nitrogen/src/builtins/opbuf"
Expand Down
139 changes: 139 additions & 0 deletions src/builtins/http/http.go
@@ -0,0 +1,139 @@
package string

import (
"io/ioutil"
"net/http"
"strings"

"github.com/nitrogen-lang/nitrogen/src/moduleutils"
"github.com/nitrogen-lang/nitrogen/src/object"
"github.com/nitrogen-lang/nitrogen/src/vm"
)

var (
moduleName = "stdlib/native/http"
client = &http.Client{}
)

func init() {
vm.RegisterModule(moduleName, &object.Module{
Name: moduleName,
Methods: map[string]object.BuiltinFunction{
"do": do,
"canonicalHeaderKey": canonicalHeaderKey,
},
Vars: map[string]object.Object{},
})
}

func do(interpreter object.Interpreter, env *object.Environment, args ...object.Object) object.Object {
if ac := moduleutils.CheckMinArgs("http.do", 2, args...); ac != nil {
return ac
}

// Argument 1 - HTTP method
methodObj, ok := args[0].(*object.String)
if !ok {
return object.NewException("http.do expected first argument to be a string, got %s", args[0].Type().String())
}
method := strings.ToUpper(methodObj.String())
if method == "" {
method = "GET"
}

// Argument 2 - URL
urlObj, ok := args[1].(*object.String)
if !ok {
return object.NewException("http.do expected second argument to be a string, got %s", args[1].Type().String())
}

url := strings.TrimSpace(urlObj.String())
if url == "" {
return object.NewException("http.do expected a non-empty string")
}

// Argument 3 - Data payload
data := ""
if len(args) >= 3 && args[2] != object.NullConst {
dataObj, ok := args[2].(*object.String)
if !ok {
return object.NewException("http.do expected third argument to be a string, got %s", args[2].Type().String())
}
data = dataObj.String()
}

// Argument 4 - Request options
var optionsObj *object.Hash
if len(args) >= 4 && args[3] != object.NullConst {
dataObj, ok := args[3].(*object.Hash)
if !ok {
return object.NewException("http.post expected fourth argument to be a map, got %s", args[3].Type().String())
}
optionsObj = dataObj
}

req, err := http.NewRequest(method, url, strings.NewReader(data))
if err != nil {
return object.NewException("error making HTTP request: %s", err.Error())
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

if optionsObj != nil {
headers := optionsObj.LookupKey("headers")
headersMap, ok := headers.(*object.Hash)
if !ok {
return object.NewException("headers option must be map")
}

for _, pair := range headersMap.Pairs {
if pair.Key.Type() != object.StringObj {
continue
}
if pair.Value.Type() != object.StringObj {
continue
}
req.Header.Set(pair.Key.(*object.String).String(), pair.Value.(*object.String).String())
}
}

resp, err := client.Do(req)

if err != nil {
return object.NewException("error making HTTP request: %s", err.Error())
}
defer resp.Body.Close()

return buildReturnValue(resp)
}

func buildReturnValue(resp *http.Response) object.Object {
body, _ := ioutil.ReadAll(resp.Body)

headers := make(map[string]string, len(resp.Header))

for name, values := range resp.Header {
headers[name] = values[0]
}

hash := &object.Hash{
Pairs: make(map[object.HashKey]object.HashPair, 2),
}

hash.SetKey("body", object.MakeStringObj(string(body)))
hash.SetKey("headers", object.StringMapToHash(headers))

return hash
}

func canonicalHeaderKey(interpreter object.Interpreter, env *object.Environment, args ...object.Object) object.Object {
if ac := moduleutils.CheckMinArgs("http.canonicalHeaderKey", 1, args...); ac != nil {
return ac
}

headerKey, ok := args[0].(*object.String)
if !ok {
return object.NewException("http.canonicalHeaderKey expected a string argument, got %s", args[0].Type().String())
}

return object.MakeStringObj(http.CanonicalHeaderKey(headerKey.String()))
}
15 changes: 15 additions & 0 deletions src/object/object.go
Expand Up @@ -360,6 +360,21 @@ func (h *Hash) Dup() Object {

return &Hash{Pairs: newElements}
}
func (h *Hash) LookupKey(key string) Object {
keyObj := MakeStringObj(key)
pair, ok := h.Pairs[keyObj.HashKey()]
if !ok {
return nil
}
return pair.Value
}
func (h *Hash) SetKey(key string, val Object) {
keyObj := MakeStringObj(key)
h.Pairs[keyObj.HashKey()] = HashPair{
Key: keyObj,
Value: val,
}
}

func StringMapToHash(src map[string]string) *Hash {
m := &Hash{Pairs: make(map[HashKey]HashPair)}
Expand Down
49 changes: 49 additions & 0 deletions tests/stdlib/http.ni
@@ -0,0 +1,49 @@
import "stdlib/http"
import "stdlib/json"
import "stdlib/test"
import "stdlib/collections"

use collections.contains

test.run("HTTP GET request", func(assert) {
const resp = http.get("https://jsonplaceholder.typicode.com/posts/1")

assert.isTrue(contains(resp, "body"))
assert.isTrue(contains(resp, "headers"))
assert.isTrue(isString(resp.body))
})

test.run("HTTP GET JSON request", func(assert) {
const resp = http.getJSON("https://jsonplaceholder.typicode.com/posts/1")

assert.isTrue(isMap(resp))
assert.isTrue(contains(resp, "id"))
assert.isTrue(contains(resp, "title"))
assert.isTrue(contains(resp, "body"))
assert.isTrue(contains(resp, "userId"))
})

test.run("HTTP POST request", func(assert) {
const data = json.encode({
"title": 'foo',
"body": 'bar',
"userId": 1,
})

const resp = http.post("https://jsonplaceholder.typicode.com/posts", data, {
"headers": {
"Content-Type": "application/json",
},
})

assert.isTrue(contains(resp, "body"))
assert.isTrue(contains(resp, "headers"))
assert.isTrue(isString(resp.body))

const respData = json.decode(resp.body)
assert.isTrue(isMap(respData))
assert.isTrue(contains(respData, "id"))
assert.isTrue(contains(respData, "title"))
assert.isTrue(contains(respData, "body"))
assert.isTrue(contains(respData, "userId"))
})

0 comments on commit 6e8e259

Please sign in to comment.