Skip to content

Commit

Permalink
[pocketbase#4544] implemented JSVM FormData and added support for $ht…
Browse files Browse the repository at this point in the history
…tp.send multipart/form-data requests
  • Loading branch information
ganigeorgiev committed Mar 12, 2024
1 parent adab0da commit 0f1b73a
Show file tree
Hide file tree
Showing 7 changed files with 8,480 additions and 7,937 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

- Removed conflicting styles causing the detailed codeblock log data preview to not be properly visualized ([#4505](https://github.com/pocketbase/pocketbase/pull/4505)).

- Minor JSVM improvements:
- Added `$filesystem.fileFromUrl(url, optSecTimeout)` helper (_similar to the Go `filesystem.NewFileFromUrl(ctx, url)`_).
- Implemented the `FormData` interface and added support for sending `multipart/form-data` requests with `$http.send()` when the body is `FormData` ([#4544](https://github.com/pocketbase/pocketbase/discussions/4544)).


## v0.22.3

Expand Down
90 changes: 73 additions & 17 deletions plugins/jsvm/binds.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -534,6 +535,16 @@ func filesystemBinds(vm *goja.Runtime) {
obj.Set("fileFromPath", filesystem.NewFileFromPath)
obj.Set("fileFromBytes", filesystem.NewFileFromBytes)
obj.Set("fileFromMultipart", filesystem.NewFileFromMultipart)
obj.Set("fileFromUrl", func(url string, secTimeout int) (*filesystem.File, error) {
if secTimeout == 0 {
secTimeout = 120
}

ctx, cancel := context.WithTimeout(context.Background(), time.Duration(secTimeout)*time.Second)
defer cancel()

return filesystem.NewFileFromUrl(ctx, url)
})
}

func filepathBinds(vm *goja.Runtime) {
Expand Down Expand Up @@ -640,34 +651,61 @@ func httpClientBinds(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$http", obj)

vm.Set("FormData", func(call goja.ConstructorCall) *goja.Object {
instance := FormData{}

instanceValue := vm.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())

return instanceValue
})

type sendResult struct {
StatusCode int `json:"statusCode"`
Json any `json:"json"`
Headers map[string][]string `json:"headers"`
Cookies map[string]*http.Cookie `json:"cookies"`
Raw string `json:"raw"`
Json any `json:"json"`
StatusCode int `json:"statusCode"`
}

type sendConfig struct {
// Deprecated: consider using Body instead
Data map[string]any

Body any // raw string or FormData
Headers map[string]string
Method string
Url string
Body string
Headers map[string]string
Timeout int // seconds (default to 120)
Data map[string]any // deprecated, consider using Body instead
Timeout int // seconds (default to 120)
}

obj.Set("send", func(params map[string]any) (*sendResult, error) {
rawParams, err := json.Marshal(params)
if err != nil {
return nil, err
}

config := sendConfig{
Method: "GET",
}
if err := json.Unmarshal(rawParams, &config); err != nil {
return nil, err

if v, ok := params["data"]; ok {
config.Data = cast.ToStringMap(v)
}

if v, ok := params["body"]; ok {
config.Body = v
}

if v, ok := params["headers"]; ok {
config.Headers = cast.ToStringMapString(v)
}

if v, ok := params["method"]; ok {
config.Method = cast.ToString(v)
}

if v, ok := params["url"]; ok {
config.Url = cast.ToString(v)
}

if v, ok := params["timeout"]; ok {
config.Timeout = cast.ToInt(v)
}

if config.Timeout <= 0 {
Expand All @@ -678,6 +716,7 @@ func httpClientBinds(vm *goja.Runtime) {
defer cancel()

var reqBody io.Reader
var contentType string

// legacy json body data
if len(config.Data) != 0 {
Expand All @@ -686,10 +725,19 @@ func httpClientBinds(vm *goja.Runtime) {
return nil, err
}
reqBody = bytes.NewReader(encoded)
}
} else {
switch v := config.Body.(type) {
case FormData:
body, mp, err := v.toMultipart()
if err != nil {
return nil, err
}

if config.Body != "" {
reqBody = strings.NewReader(config.Body)
reqBody = body
contentType = mp.FormDataContentType()
default:
reqBody = strings.NewReader(cast.ToString(config.Body))
}
}

req, err := http.NewRequestWithContext(ctx, strings.ToUpper(config.Method), config.Url, reqBody)
Expand All @@ -701,7 +749,15 @@ func httpClientBinds(vm *goja.Runtime) {
req.Header.Add(k, v)
}

// set default content-type header (if missing)
// set the explicit content type
// (overwriting the user provided header value if any)
if contentType != "" {
req.Header.Set("content-type", contentType)
}

// @todo consider removing during the refactoring
//
// fallback to json content-type
if req.Header.Get("content-type") == "" {
req.Header.Set("content-type", "application/json")
}
Expand Down
73 changes: 70 additions & 3 deletions plugins/jsvm/binds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package jsvm

import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
Expand Down Expand Up @@ -890,13 +891,23 @@ func TestFilesystemBinds(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/error" {
w.WriteHeader(http.StatusInternalServerError)
}

fmt.Fprintf(w, "test")
}))
defer srv.Close()

vm := goja.New()
vm.Set("mh", &multipart.FileHeader{Filename: "test"})
vm.Set("testFile", filepath.Join(app.DataDir(), "data.db"))
vm.Set("baseUrl", srv.URL)
baseBinds(vm)
filesystemBinds(vm)

testBindsCount(vm, "$filesystem", 3, t)
testBindsCount(vm, "$filesystem", 4, t)

// fileFromPath
{
Expand Down Expand Up @@ -939,6 +950,28 @@ func TestFilesystemBinds(t *testing.T) {
t.Fatalf("[fileFromMultipart] Expected file with name %q, got %v", file.OriginalName, file)
}
}

// fileFromUrl (success)
{
v, err := vm.RunString(`$filesystem.fileFromUrl(baseUrl + "/test")`)
if err != nil {
t.Fatal(err)
}

file, _ := v.Export().(*filesystem.File)

if file == nil || file.OriginalName != "test" {
t.Fatalf("[fileFromUrl] Expected file with name %q, got %v", file.OriginalName, file)
}
}

// fileFromUrl (failure)
{
_, err := vm.RunString(`$filesystem.fileFromUrl(baseUrl + "/error")`)
if err == nil {
t.Fatal("Expected url fetch error")
}
}
}

func TestFormsBinds(t *testing.T) {
Expand Down Expand Up @@ -1121,6 +1154,7 @@ func TestHttpClientBindsCount(t *testing.T) {
vm := goja.New()
httpClientBinds(vm)

testBindsCount(vm, "this", 2, t) // + FormData
testBindsCount(vm, "$http", 1, t)
}

Expand Down Expand Up @@ -1223,6 +1257,15 @@ func TestHttpClientBindsSend(t *testing.T) {
headers: {"content-type": "text/plain"},
})
// with FormData
const formData = new FormData()
formData.append("title", "123")
const test3 = $http.send({
url: testUrl,
body: formData,
headers: {"content-type": "text/plain"}, // should be ignored
})
const scenarios = [
[test0, {
"statusCode": "400",
Expand All @@ -1244,15 +1287,39 @@ func TestHttpClientBindsSend(t *testing.T) {
"json.method": "GET",
"json.headers.content_type": "text/plain",
}],
[test3, {
"statusCode": "200",
"headers.X-Custom.0": "custom_header",
"cookies.sessionId.value": "123456",
"json.method": "GET",
"json.body": [
"\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\n123\r\n--",
],
"json.headers.content_type": [
"multipart/form-data; boundary="
],
}],
]
for (let scenario of scenarios) {
const result = scenario[0];
const expectations = scenario[1];
for (let key in expectations) {
if (getNestedVal(result, key) != expectations[key]) {
throw new Error('Expected ' + key + ' ' + expectations[key] + ', got: ' + result.raw);
const value = getNestedVal(result, key);
const expectation = expectations[key]
if (Array.isArray(expectation)) {
// check for partial match(es)
for (let exp of expectation) {
if (!value.includes(exp)) {
throw new Error('Expected ' + key + ' to contain ' + exp + ', got: ' + result.raw);
}
}
} else {
// check for direct match
if (value != expectation) {
throw new Error('Expected ' + key + ' ' + expectation + ', got: ' + result.raw);
}
}
}
}
Expand Down
Loading

0 comments on commit 0f1b73a

Please sign in to comment.