Skip to content

Commit

Permalink
feat: tengo http support (#71)
Browse files Browse the repository at this point in the history
* feat: added support for http module in tengo

* unit test

* removed test

* unit test check

* fixed lint

* fixed lint

* generic with support for headers

* fixed lint

* made the HTTP userfunc modular - resolved comments

* removed redundant line

* check unit test

* fix check unit tests

* fix return values for unit test

* fix timeout unit test

* small typo fix

* fix check tests

* fixed test for header value type

* fixed test for header value type

* httpModule export not required

---------

Co-authored-by: Sumeet Rai <[email protected]>
  • Loading branch information
sumslim and Sumeet Rai authored Dec 2, 2024
1 parent 1eaeb72 commit c51a7d2
Show file tree
Hide file tree
Showing 2 changed files with 155 additions and 2 deletions.
97 changes: 95 additions & 2 deletions plugins/internal/tengoutil/secure_script.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package tengoutil

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"

"github.com/d5/tengo/v2"
"github.com/d5/tengo/v2/stdlib"
Expand All @@ -12,13 +17,23 @@ const (
maxConsts = 500
)

const expectedArgsLength = 2

var defaultTimeout = 5 * time.Second

var httpModule = map[string]tengo.Object{
"get": httpGetFunction,
}

func NewSecureScript(input []byte, globals map[string]interface{}) (*tengo.Script, error) {
s := tengo.NewScript(input)

s.SetImports(stdlib.GetModuleMap(
modules := stdlib.GetModuleMap(
// `os` is excluded, should *not* be importable from script.
"math", "text", "times", "rand", "fmt", "json", "base64", "hex", "enum",
))
)
modules.AddBuiltinModule("http", httpModule)
s.SetImports(modules)
s.SetMaxAllocs(maxAllocs)
s.SetMaxConstObjects(maxConsts)

Expand All @@ -30,3 +45,81 @@ func NewSecureScript(input []byte, globals map[string]interface{}) (*tengo.Scrip

return s, nil
}

var httpGetFunction = &tengo.UserFunction{
Name: "get",
Value: func(args ...tengo.Object) (tengo.Object, error) {
url, err := extractURL(args)
if err != nil {
return nil, err
}
headers, err := extractHeaders(args)
if err != nil {
return nil, err
}

return performGetRequest(url, headers, defaultTimeout)
},
}

func extractURL(args []tengo.Object) (string, error) {
if len(args) < 1 {
return "", errors.New("expected at least 1 argument (URL)")
}
url, ok := tengo.ToString(args[0])
if !ok {
return "", errors.New("expected argument 1 (URL) to be a string")
}

return url, nil
}

func extractHeaders(args []tengo.Object) (map[string]string, error) {
headers := make(map[string]string)
if len(args) == expectedArgsLength {
headerMap, ok := args[1].(*tengo.Map)
if !ok {
return nil, fmt.Errorf("expected argument %d (headers) to be a map", expectedArgsLength)
}
for key, value := range headerMap.Value {
strValue, valueOk := tengo.ToString(value)
if !valueOk {
return nil, fmt.Errorf("header value for key '%s' must be a string, got %T", key, value)
}
headers[key] = strValue
}
}

return headers, nil
}

func performGetRequest(url string, headers map[string]string, timeout time.Duration) (tengo.Object, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for key, value := range headers {
req.Header.Add(key, value)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}

return &tengo.Map{
Value: map[string]tengo.Object{
"body": &tengo.String{Value: string(body)},
"code": &tengo.Int{Value: int64(resp.StatusCode)},
},
}, nil
}
60 changes: 60 additions & 0 deletions plugins/internal/tengoutil/secure_script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package tengoutil

import (
"testing"
"time"

"github.com/MakeNowJust/heredoc"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -55,4 +56,63 @@ func TestNewSecureScript(t *testing.T) {
_, err = s.Compile()
assert.NoError(t, err)
})

t.Run("Allows import of custom http module", func(t *testing.T) {
s, err := NewSecureScript(([]byte)(heredoc.Doc(`
http := import("http")
response := http.get("http://example.com")
response.body
`)), nil)
assert.NoError(t, err)
_, err = s.Compile()
assert.NoError(t, err)
})

t.Run("HTTP GET with headers", func(t *testing.T) {
s, err := NewSecureScript(([]byte)(heredoc.Doc(`
http := import("http")
headers := { "User-Agent": "test-agent", "Accept": "application/json" }
response := http.get("http://example.com", headers)
response.body
`)), nil)
assert.NoError(t, err)

_, err = s.Compile()
assert.NoError(t, err)
})

t.Run("HTTP GET with invalid URL argument type", func(t *testing.T) {
s, err := NewSecureScript(([]byte)(heredoc.Doc(`
http := import("http")
http.get(12345)
`)), nil)
assert.NoError(t, err)

_, err = s.Compile()
assert.NoError(t, err)

_, err = s.Run()
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported protocol scheme")
})

t.Run("HTTP GET with timeout", func(t *testing.T) {
s, err := NewSecureScript(([]byte)(heredoc.Doc(`
http := import("http")
response := http.get("http://example.com")
response.body
`)), nil)
assert.NoError(t, err)

originalTimeout := defaultTimeout
defaultTimeout = 1 * time.Millisecond
defer func() { defaultTimeout = originalTimeout }()

_, err = s.Compile()
assert.NoError(t, err)

_, err = s.Run()
assert.Error(t, err)
assert.Contains(t, err.Error(), "context deadline exceeded")
})
}

0 comments on commit c51a7d2

Please sign in to comment.