[#4544] implemented JSVM FormData and added support for $http.send multipart/form-data requests

This commit is contained in:
Gani Georgiev 2024-03-12 21:35:29 +02:00
parent adab0da179
commit 0f1b73a4f5
7 changed files with 5077 additions and 4534 deletions

View File

@ -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)). - 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 ## v0.22.3

View File

@ -38,6 +38,7 @@ import (
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/subscriptions" "github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -534,6 +535,16 @@ func filesystemBinds(vm *goja.Runtime) {
obj.Set("fileFromPath", filesystem.NewFileFromPath) obj.Set("fileFromPath", filesystem.NewFileFromPath)
obj.Set("fileFromBytes", filesystem.NewFileFromBytes) obj.Set("fileFromBytes", filesystem.NewFileFromBytes)
obj.Set("fileFromMultipart", filesystem.NewFileFromMultipart) 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) { func filepathBinds(vm *goja.Runtime) {
@ -640,34 +651,61 @@ func httpClientBinds(vm *goja.Runtime) {
obj := vm.NewObject() obj := vm.NewObject()
vm.Set("$http", obj) 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 { type sendResult struct {
StatusCode int `json:"statusCode"` Json any `json:"json"`
Headers map[string][]string `json:"headers"` Headers map[string][]string `json:"headers"`
Cookies map[string]*http.Cookie `json:"cookies"` Cookies map[string]*http.Cookie `json:"cookies"`
Raw string `json:"raw"` Raw string `json:"raw"`
Json any `json:"json"` StatusCode int `json:"statusCode"`
} }
type sendConfig struct { type sendConfig struct {
// Deprecated: consider using Body instead
Data map[string]any
Body any // raw string or FormData
Headers map[string]string
Method string Method string
Url string Url string
Body string
Headers map[string]string
Timeout int // seconds (default to 120) Timeout int // seconds (default to 120)
Data map[string]any // deprecated, consider using Body instead
} }
obj.Set("send", func(params map[string]any) (*sendResult, error) { obj.Set("send", func(params map[string]any) (*sendResult, error) {
rawParams, err := json.Marshal(params)
if err != nil {
return nil, err
}
config := sendConfig{ config := sendConfig{
Method: "GET", 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 { if config.Timeout <= 0 {
@ -678,6 +716,7 @@ func httpClientBinds(vm *goja.Runtime) {
defer cancel() defer cancel()
var reqBody io.Reader var reqBody io.Reader
var contentType string
// legacy json body data // legacy json body data
if len(config.Data) != 0 { if len(config.Data) != 0 {
@ -686,10 +725,19 @@ func httpClientBinds(vm *goja.Runtime) {
return nil, err return nil, err
} }
reqBody = bytes.NewReader(encoded) 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 = body
reqBody = strings.NewReader(config.Body) contentType = mp.FormDataContentType()
default:
reqBody = strings.NewReader(cast.ToString(config.Body))
}
} }
req, err := http.NewRequestWithContext(ctx, strings.ToUpper(config.Method), config.Url, reqBody) req, err := http.NewRequestWithContext(ctx, strings.ToUpper(config.Method), config.Url, reqBody)
@ -701,7 +749,15 @@ func httpClientBinds(vm *goja.Runtime) {
req.Header.Add(k, v) 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") == "" { if req.Header.Get("content-type") == "" {
req.Header.Set("content-type", "application/json") req.Header.Set("content-type", "application/json")
} }

View File

@ -2,6 +2,7 @@ package jsvm
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@ -890,13 +891,23 @@ func TestFilesystemBinds(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() 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 := goja.New()
vm.Set("mh", &multipart.FileHeader{Filename: "test"}) vm.Set("mh", &multipart.FileHeader{Filename: "test"})
vm.Set("testFile", filepath.Join(app.DataDir(), "data.db")) vm.Set("testFile", filepath.Join(app.DataDir(), "data.db"))
vm.Set("baseUrl", srv.URL)
baseBinds(vm) baseBinds(vm)
filesystemBinds(vm) filesystemBinds(vm)
testBindsCount(vm, "$filesystem", 3, t) testBindsCount(vm, "$filesystem", 4, t)
// fileFromPath // fileFromPath
{ {
@ -939,6 +950,28 @@ func TestFilesystemBinds(t *testing.T) {
t.Fatalf("[fileFromMultipart] Expected file with name %q, got %v", file.OriginalName, file) 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) { func TestFormsBinds(t *testing.T) {
@ -1121,6 +1154,7 @@ func TestHttpClientBindsCount(t *testing.T) {
vm := goja.New() vm := goja.New()
httpClientBinds(vm) httpClientBinds(vm)
testBindsCount(vm, "this", 2, t) // + FormData
testBindsCount(vm, "$http", 1, t) testBindsCount(vm, "$http", 1, t)
} }
@ -1223,6 +1257,15 @@ func TestHttpClientBindsSend(t *testing.T) {
headers: {"content-type": "text/plain"}, 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 = [ const scenarios = [
[test0, { [test0, {
"statusCode": "400", "statusCode": "400",
@ -1244,6 +1287,18 @@ func TestHttpClientBindsSend(t *testing.T) {
"json.method": "GET", "json.method": "GET",
"json.headers.content_type": "text/plain", "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) { for (let scenario of scenarios) {
@ -1251,8 +1306,20 @@ func TestHttpClientBindsSend(t *testing.T) {
const expectations = scenario[1]; const expectations = scenario[1];
for (let key in expectations) { for (let key in expectations) {
if (getNestedVal(result, key) != expectations[key]) { const value = getNestedVal(result, key);
throw new Error('Expected ' + key + ' ' + expectations[key] + ', got: ' + result.raw); 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);
}
} }
} }
} }

147
plugins/jsvm/form_data.go Normal file
View File

@ -0,0 +1,147 @@
package jsvm
import (
"bytes"
"io"
"mime/multipart"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/spf13/cast"
)
// FormData represents an interface similar to the browser's [FormData].
//
// The value of each FormData entry must be a string or [*filesystem.File] instance.
//
// It is intended to be used together by the JSVM `$http.send` for
// sending multipart/form-data requests.
//
// [FormData]: https://developer.mozilla.org/en-US/docs/Web/API/FormData.
type FormData map[string][]any
// Append appends a new value onto an existing key inside the current FormData,
// or adds the key if it does not already exist.
func (data FormData) Append(key string, value any) {
data[key] = append(data[key], value)
}
// Set sets a new value for an existing key inside the current FormData,
// or adds the key/value if it does not already exist.
func (data FormData) Set(key string, value any) {
data[key] = []any{value}
}
// Delete deletes a key and its value(s) from the current FormData.
func (data FormData) Delete(key string) {
delete(data, key)
}
// Get returns the first value associated with a given key from
// within the current FormData.
//
// If you expect multiple values and want all of them,
// use the [FormData.GetAll] method instead.
func (data FormData) Get(key string) any {
values, ok := data[key]
if !ok || len(values) == 0 {
return nil
}
return values[0]
}
// GetAll returns all the values associated with a given key
// from within the current FormData.
func (data FormData) GetAll(key string) []any {
values, ok := data[key]
if !ok {
return nil
}
return values
}
// Has returns whether a FormData object contains a certain key.
func (data FormData) Has(key string) bool {
values, ok := data[key]
return ok && len(values) > 0
}
// Keys returns all keys contained in the current FormData.
func (data FormData) Keys() []string {
result := make([]string, 0, len(data))
for k := range data {
result = append(result, k)
}
return result
}
// Keys returns all values contained in the current FormData.
func (data FormData) Values() []any {
result := make([]any, 0, len(data))
for _, values := range data {
for _, v := range values {
result = append(result, v)
}
}
return result
}
// Entries returns a [key, value] slice pair for each FormData entry.
func (data FormData) Entries() [][]any {
result := make([][]any, 0, len(data))
for k, values := range data {
for _, v := range values {
result = append(result, []any{k, v})
}
}
return result
}
// toMultipart converts the current FormData entries into multipart encoded data.
func (data FormData) toMultipart() (*bytes.Buffer, *multipart.Writer, error) {
body := new(bytes.Buffer)
mp := multipart.NewWriter(body)
defer mp.Close()
for k, values := range data {
for _, rawValue := range values {
switch v := rawValue.(type) {
case *filesystem.File:
err := func() error {
mpw, err := mp.CreateFormFile(k, v.OriginalName)
if err != nil {
return err
}
file, err := v.Reader.Open()
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(mpw, file); err != nil {
return err
}
return nil
}()
if err != nil {
return nil, nil, err
}
default:
mp.WriteField(k, cast.ToString(v))
}
}
}
return body, mp, nil
}

View File

@ -0,0 +1,225 @@
package jsvm
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestFormDataAppendAndSet(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
data.Append("b", 3)
data.Append("b", 4)
data.Set("b", 5) // should overwrite the previous 2 calls
data.Set("c", 6)
data.Set("c", 7)
if len(data["a"]) != 2 {
t.Fatalf("Expected 2 'a' values, got %v", data["a"])
}
if data["a"][0] != 1 || data["a"][1] != 2 {
t.Fatalf("Expected 1 and 2 'a' key values, got %v", data["a"])
}
if len(data["b"]) != 1 {
t.Fatalf("Expected 1 'b' values, got %v", data["b"])
}
if data["b"][0] != 5 {
t.Fatalf("Expected 5 as 'b' key value, got %v", data["b"])
}
if len(data["c"]) != 1 {
t.Fatalf("Expected 1 'c' values, got %v", data["c"])
}
if data["c"][0] != 7 {
t.Fatalf("Expected 7 as 'c' key value, got %v", data["c"])
}
}
func TestFormDataDelete(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
data.Append("b", 3)
data.Delete("missing") // should do nothing
data.Delete("a")
if len(data) != 1 {
t.Fatalf("Expected exactly 1 data remaining key, got %v", data)
}
if data["b"][0] != 3 {
t.Fatalf("Expected 3 as 'b' key value, got %v", data["b"])
}
}
func TestFormDataGet(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
if v := data.Get("missing"); v != nil {
t.Fatalf("Expected %v for key 'missing', got %v", nil, v)
}
if v := data.Get("a"); v != 1 {
t.Fatalf("Expected %v for key 'a', got %v", 1, v)
}
}
func TestFormDataGetAll(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("a", 2)
if v := data.GetAll("missing"); v != nil {
t.Fatalf("Expected %v for key 'a', got %v", nil, v)
}
values := data.GetAll("a")
if len(values) != 2 || values[0] != 1 || values[1] != 2 {
t.Fatalf("Expected 1 and 2 values, got %v", values)
}
}
func TestFormDataHas(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
if v := data.Has("missing"); v {
t.Fatalf("Expected key 'missing' to not exist: %v", v)
}
if v := data.Has("a"); !v {
t.Fatalf("Expected key 'a' to exist: %v", v)
}
}
func TestFormDataKeys(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("b", 1)
data.Append("c", 1)
data.Append("a", 1)
keys := data.Keys()
expectedKeys := []string{"a", "b", "c"}
for _, expected := range expectedKeys {
if !list.ExistInSlice(expected, keys) {
t.Fatalf("Expected key %s to exists in %v", expected, keys)
}
}
}
func TestFormDataValues(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("b", 2)
data.Append("c", 3)
data.Append("a", 4)
values := data.Values()
expectedKeys := []any{1, 2, 3, 4}
for _, expected := range expectedKeys {
if !list.ExistInSlice(expected, values) {
t.Fatalf("Expected key %s to exists in %v", expected, values)
}
}
}
func TestFormDataEntries(t *testing.T) {
t.Parallel()
data := FormData{}
data.Append("a", 1)
data.Append("b", 2)
data.Append("c", 3)
data.Append("a", 4)
entries := data.Entries()
rawEntries, err := json.Marshal(entries)
if err != nil {
t.Fatal(err)
}
if len(entries) != 4 {
t.Fatalf("Expected 4 entries")
}
expectedEntries := []string{`["a",1]`, `["a",4]`, `["b",2]`, `["c",3]`}
for _, expected := range expectedEntries {
if !bytes.Contains(rawEntries, []byte(expected)) {
t.Fatalf("Expected entry %s to exists in %s", expected, rawEntries)
}
}
}
func TestFormDataToMultipart(t *testing.T) {
t.Parallel()
f, err := filesystem.NewFileFromBytes([]byte("abc"), "test")
if err != nil {
t.Fatal(err)
}
data := FormData{}
data.Append("a", 1) // should be casted
data.Append("b", "test1")
data.Append("b", "test2")
data.Append("c", f)
body, mp, err := data.toMultipart()
if err != nil {
t.Fatal(err)
}
bodyStr := body.String()
// content type checks
contentType := mp.FormDataContentType()
expectedContentType := "multipart/form-data; boundary="
if !strings.Contains(contentType, expectedContentType) {
t.Fatalf("Expected to find content-type %s in %s", expectedContentType, contentType)
}
// body checks
expectedBodyParts := []string{
"Content-Disposition: form-data; name=\"a\"\r\n\r\n1",
"Content-Disposition: form-data; name=\"b\"\r\n\r\ntest1",
"Content-Disposition: form-data; name=\"b\"\r\n\r\ntest2",
"Content-Disposition: form-data; name=\"c\"; filename=\"test\"\r\nContent-Type: application/octet-stream\r\n\r\nabc",
}
for _, part := range expectedBodyParts {
if !strings.Contains(bodyStr, part) {
t.Fatalf("Expected to find %s in body\n%s", part, bodyStr)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -641,6 +641,22 @@ declare namespace $filesystem {
let fileFromPath: filesystem.newFileFromPath let fileFromPath: filesystem.newFileFromPath
let fileFromBytes: filesystem.newFileFromBytes let fileFromBytes: filesystem.newFileFromBytes
let fileFromMultipart: filesystem.newFileFromMultipart let fileFromMultipart: filesystem.newFileFromMultipart
/**
* fileFromUrl creates a new File from the provided url by
* downloading the resource and creating a BytesReader.
*
* Example:
*
* ` + "```" + `js
* // with default max timeout of 120sec
* const file1 = $filesystem.fileFromUrl("https://...")
*
* // with custom timeout of 15sec
* const file2 = $filesystem.fileFromUrl("https://...", 15)
* ` + "```" + `
*/
export function fileFromUrl(url: string, secTimeout?: number): filesystem.File
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@ -988,6 +1004,12 @@ declare namespace $apis {
// httpClientBinds // httpClientBinds
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// extra FormData overload to prevent TS warnings when used with non File/Blob value.
interface FormData {
append(key:string, value:any): void
set(key:string, value:any): void
}
/** /**
* ` + "`" + `$http` + "`" + ` defines common methods for working with HTTP requests. * ` + "`" + `$http` + "`" + ` defines common methods for working with HTTP requests.
* *
@ -1002,7 +1024,7 @@ declare namespace $http {
* ` + "```" + `js * ` + "```" + `js
* const res = $http.send({ * const res = $http.send({
* url: "https://example.com", * url: "https://example.com",
* data: {"title": "test"} * body: JSON.stringify({"title": "test"})
* method: "post", * method: "post",
* }) * })
* *
@ -1015,7 +1037,7 @@ declare namespace $http {
*/ */
function send(config: { function send(config: {
url: string, url: string,
body?: string, body?: string|FormData,
method?: string, // default to "GET" method?: string, // default to "GET"
headers?: { [key:string]: string }, headers?: { [key:string]: string },
timeout?: number, // default to 120 timeout?: number, // default to 120