added jsvm bindings and updateing the workflow to generate the jsvm types
This commit is contained in:
parent
3d3fe5c614
commit
ede67dbc20
|
@ -29,6 +29,11 @@ jobs:
|
||||||
- name: Build Admin dashboard UI
|
- name: Build Admin dashboard UI
|
||||||
run: npm --prefix=./ui ci && npm --prefix=./ui run build
|
run: npm --prefix=./ui ci && npm --prefix=./ui run build
|
||||||
|
|
||||||
|
# Similar to the above, the jsvm docs are pregenerated locally
|
||||||
|
# but its here to ensure that it wasn't forgotten to be executed.
|
||||||
|
- name: Generate jsvm types
|
||||||
|
run: go run ./plugins/jsvm/internal/docs.go
|
||||||
|
|
||||||
# The prebuilt golangci-lint doesn't support go 1.18+ yet
|
# The prebuilt golangci-lint doesn't support go 1.18+ yet
|
||||||
# https://github.com/golangci/golangci-lint/issues/2649
|
# https://github.com/golangci/golangci-lint/issues/2649
|
||||||
# - name: Run linter
|
# - name: Run linter
|
||||||
|
|
|
@ -75,6 +75,8 @@
|
||||||
|
|
||||||
- Allowed `0` as `RelationOptions.MinSelect` value to avoid the ambiguity between 0 and non-filled input value ([#2817](https://github.com/pocketbase/pocketbase/discussions/2817)).
|
- Allowed `0` as `RelationOptions.MinSelect` value to avoid the ambiguity between 0 and non-filled input value ([#2817](https://github.com/pocketbase/pocketbase/discussions/2817)).
|
||||||
|
|
||||||
|
- Minor Admin UI fixes (typos, grammar fixes, removed unnecessary 404 error check, etc.).
|
||||||
|
|
||||||
|
|
||||||
## v0.16.8
|
## v0.16.8
|
||||||
|
|
||||||
|
|
3
Makefile
3
Makefile
|
@ -4,6 +4,9 @@ lint:
|
||||||
test:
|
test:
|
||||||
go test ./... -v --cover
|
go test ./... -v --cover
|
||||||
|
|
||||||
|
jsvmdocs:
|
||||||
|
go run ./plugins/jsvm/internal/docs/docs.go
|
||||||
|
|
||||||
test-report:
|
test-report:
|
||||||
go test ./... -v --cover -coverprofile=coverage.out
|
go test ./... -v --cover -coverprofile=coverage.out
|
||||||
go tool cover -html=coverage.out
|
go tool cover -html=coverage.out
|
||||||
|
|
|
@ -42,7 +42,7 @@ func main() {
|
||||||
app.RootCmd.PersistentFlags().IntVar(
|
app.RootCmd.PersistentFlags().IntVar(
|
||||||
&hooksPool,
|
&hooksPool,
|
||||||
"hooksPool",
|
"hooksPool",
|
||||||
120,
|
100,
|
||||||
"the total prewarm goja.Runtime instances for the JS app hooks execution",
|
"the total prewarm goja.Runtime instances for the JS app hooks execution",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,16 @@
|
||||||
package jsvm
|
package jsvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
|
@ -72,16 +78,21 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
||||||
|
|
||||||
// check for returned hook.StopPropagation
|
// check for returned hook.StopPropagation
|
||||||
if res != nil {
|
if res != nil {
|
||||||
if v, ok := res.Export().(error); ok {
|
exported := res.Export()
|
||||||
return v
|
if exported != nil {
|
||||||
|
if v, ok := exported.(error); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// check for throwed hook.StopPropagation
|
// check for throwed hook.StopPropagation
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exception, ok := err.(*goja.Exception)
|
if exception, ok := err.(*goja.Exception); ok {
|
||||||
if ok && errors.Is(exception.Value().Export().(error), hook.StopPropagation) {
|
v, ok := exception.Value().Export().(error)
|
||||||
return hook.StopPropagation
|
if ok && errors.Is(v, hook.StopPropagation) {
|
||||||
|
return hook.StopPropagation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -463,6 +474,100 @@ func apisBinds(vm *goja.Runtime) {
|
||||||
registerFactoryAsConstructor(vm, "UnauthorizedError", apis.NewUnauthorizedError)
|
registerFactoryAsConstructor(vm, "UnauthorizedError", apis.NewUnauthorizedError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func httpClientBinds(vm *goja.Runtime) {
|
||||||
|
obj := vm.NewObject()
|
||||||
|
vm.Set("$http", obj)
|
||||||
|
|
||||||
|
type sendResult struct {
|
||||||
|
StatusCode int
|
||||||
|
Raw string
|
||||||
|
Json any
|
||||||
|
}
|
||||||
|
|
||||||
|
type sendConfig struct {
|
||||||
|
Method string
|
||||||
|
Url string
|
||||||
|
Data map[string]any
|
||||||
|
Headers map[string]string
|
||||||
|
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 config.Timeout <= 0 {
|
||||||
|
config.Timeout = 120
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Timeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var reqBody io.Reader
|
||||||
|
if len(config.Data) != 0 {
|
||||||
|
encoded, err := json.Marshal(config.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reqBody = bytes.NewReader(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, strings.ToUpper(config.Method), config.Url, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range config.Headers {
|
||||||
|
req.Header.Add(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set default content-type header (if missing)
|
||||||
|
if req.Header.Get("content-type") == "" {
|
||||||
|
req.Header.Set("content-type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("request failed with status %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyRaw, _ := io.ReadAll(res.Body)
|
||||||
|
|
||||||
|
result := &sendResult{
|
||||||
|
StatusCode: res.StatusCode,
|
||||||
|
Raw: string(bodyRaw),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Raw) != 0 {
|
||||||
|
// try as map
|
||||||
|
result.Json = map[string]any{}
|
||||||
|
if err := json.Unmarshal(bodyRaw, &result.Json); err != nil {
|
||||||
|
// try as slice
|
||||||
|
result.Json = []any{}
|
||||||
|
if err := json.Unmarshal(bodyRaw, &result.Json); err != nil {
|
||||||
|
result.Json = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
// registerFactoryAsConstructor registers the factory function as native JS constructor.
|
// registerFactoryAsConstructor registers the factory function as native JS constructor.
|
||||||
|
|
|
@ -2,9 +2,15 @@ package jsvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
|
@ -760,11 +766,11 @@ func TestLoadingDynamicModel(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
$app.dao().db()
|
$app.dao().db()
|
||||||
.select("text", "bool", "number", "select_many", "json", "('{\"test\": 1}') as obj")
|
.select("text", "bool", "number", "select_many", "json", "('{\"test\": 1}') as obj")
|
||||||
.from("demo1")
|
.from("demo1")
|
||||||
.where($dbx.hashExp({"id": "84nmscqy84lsi1t"}))
|
.where($dbx.hashExp({"id": "84nmscqy84lsi1t"}))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.one(result)
|
.one(result)
|
||||||
|
|
||||||
if (result.text != "test") {
|
if (result.text != "test") {
|
||||||
throw new Error('Expected text "test", got ' + result.text);
|
throw new Error('Expected text "test", got ' + result.text);
|
||||||
|
@ -811,12 +817,12 @@ func TestLoadingArrayOf(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
|
|
||||||
$app.dao().db()
|
$app.dao().db()
|
||||||
.select("id", "text")
|
.select("id", "text")
|
||||||
.from("demo1")
|
.from("demo1")
|
||||||
.where($dbx.exp("id='84nmscqy84lsi1t' OR id='al1h9ijdeojtsjy'"))
|
.where($dbx.exp("id='84nmscqy84lsi1t' OR id='al1h9ijdeojtsjy'"))
|
||||||
.limit(2)
|
.limit(2)
|
||||||
.orderBy("text ASC")
|
.orderBy("text ASC")
|
||||||
.all(result)
|
.all(result)
|
||||||
|
|
||||||
if (result.length != 2) {
|
if (result.length != 2) {
|
||||||
throw new Error('Expected 2 list items, got ' + result.length);
|
throw new Error('Expected 2 list items, got ' + result.length);
|
||||||
|
@ -840,3 +846,144 @@ func TestLoadingArrayOf(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHttpClientBindsCount(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
vm := goja.New()
|
||||||
|
httpClientBinds(vm)
|
||||||
|
|
||||||
|
testBindsCount(vm, "$http", 1, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHttpClientBindsSend(t *testing.T) {
|
||||||
|
// start a test server
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Query().Get("testError") != "" {
|
||||||
|
rw.WriteHeader(400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutStr := req.URL.Query().Get("testTimeout")
|
||||||
|
timeout, _ := strconv.Atoi(timeoutStr)
|
||||||
|
if timeout > 0 {
|
||||||
|
time.Sleep(time.Duration(timeout) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyRaw, _ := io.ReadAll(req.Body)
|
||||||
|
defer req.Body.Close()
|
||||||
|
body := map[string]any{}
|
||||||
|
json.Unmarshal(bodyRaw, &body)
|
||||||
|
|
||||||
|
// normalize headers
|
||||||
|
headers := make(map[string]string, len(req.Header))
|
||||||
|
for k, v := range req.Header {
|
||||||
|
if len(v) > 0 {
|
||||||
|
headers[strings.ToLower(strings.ReplaceAll(k, "-", "_"))] = v[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info := map[string]any{
|
||||||
|
"method": req.Method,
|
||||||
|
"headers": headers,
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
|
||||||
|
infoRaw, _ := json.Marshal(info)
|
||||||
|
|
||||||
|
// write back the submitted request
|
||||||
|
rw.Write(infoRaw)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
vm := goja.New()
|
||||||
|
baseBinds(vm)
|
||||||
|
httpClientBinds(vm)
|
||||||
|
vm.Set("testUrl", server.URL)
|
||||||
|
|
||||||
|
_, err := vm.RunString(`
|
||||||
|
function getNestedVal(data, path) {
|
||||||
|
let result = data || {};
|
||||||
|
let parts = path.split(".");
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (
|
||||||
|
result == null ||
|
||||||
|
typeof result !== "object" ||
|
||||||
|
typeof result[part] === "undefined"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result[part];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
let testErr;
|
||||||
|
try {
|
||||||
|
$http.send({ url: testUrl + "?testError=1" })
|
||||||
|
} catch (err) {
|
||||||
|
testErr = err
|
||||||
|
}
|
||||||
|
if (!testErr) {
|
||||||
|
throw new Error("Expected an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
let testTimeout;
|
||||||
|
try {
|
||||||
|
$http.send({
|
||||||
|
url: testUrl + "?testTimeout=3",
|
||||||
|
timeout: 1
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
testTimeout = err
|
||||||
|
}
|
||||||
|
if (!testTimeout) {
|
||||||
|
throw new Error("Expected timeout error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// basic fields check
|
||||||
|
const test1 = $http.send({
|
||||||
|
method: "post",
|
||||||
|
url: testUrl,
|
||||||
|
data: {"data": "example"},
|
||||||
|
headers: {"header1": "123", "header2": "456"}
|
||||||
|
})
|
||||||
|
|
||||||
|
// with custom content-type header
|
||||||
|
const test2 = $http.send({
|
||||||
|
url: testUrl,
|
||||||
|
headers: {"content-type": "text/plain"}
|
||||||
|
})
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
[test1, {
|
||||||
|
"json.method": "POST",
|
||||||
|
"json.headers.header1": "123",
|
||||||
|
"json.headers.header2": "456",
|
||||||
|
"json.headers.content_type": "application/json", // default
|
||||||
|
}],
|
||||||
|
[test2, {
|
||||||
|
"json.method": "GET",
|
||||||
|
"json.headers.content_type": "text/plain",
|
||||||
|
}],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let scenario in 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,9 @@ package main
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
@ -646,6 +648,29 @@ declare namespace $apis {
|
||||||
let enrichRecords: apis.enrichRecords
|
let enrichRecords: apis.enrichRecords
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// httpClientBinds
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
declare namespace $http {
|
||||||
|
/**
|
||||||
|
* Sends a single HTTP request (_currently only json and plain text requests_).
|
||||||
|
*
|
||||||
|
* @group PocketBase
|
||||||
|
*/
|
||||||
|
function send(params: {
|
||||||
|
url: string,
|
||||||
|
method?: string, // default to "GET"
|
||||||
|
data?: { [key:string]: any },
|
||||||
|
headers?: { [key:string]: string },
|
||||||
|
timeout?: number // default to 120
|
||||||
|
}): {
|
||||||
|
statusCode: number
|
||||||
|
raw: string
|
||||||
|
json: any
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// migrate only
|
// migrate only
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
@ -695,7 +720,15 @@ func main() {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile("./generated/types.d.ts", []byte(result), 0644); err != nil {
|
_, filename, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
log.Fatal("Failed to get the current docs directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
parentDir := filepath.Dir(filename)
|
||||||
|
typesFile := filepath.Join(parentDir, "generated", "types.d.ts")
|
||||||
|
|
||||||
|
if err := os.WriteFile(typesFile, []byte(result), 0644); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -162,13 +162,14 @@ func (p *plugin) registerHooks() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepend the types reference directive to empty files
|
// prepend the types reference directive
|
||||||
//
|
//
|
||||||
// note: it is loaded during startup to handle conveniently also
|
// note: it is loaded during startup to handle conveniently also
|
||||||
// the case when the HooksWatch option is enabled and the application
|
// the case when the HooksWatch option is enabled and the application
|
||||||
// restart on newly created file
|
// restart on newly created file
|
||||||
for name, content := range files {
|
for name, content := range files {
|
||||||
if len(content) != 0 {
|
if len(content) != 0 {
|
||||||
|
// skip non-empty files for now to prevent accidental overwrite
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path := filepath.Join(p.config.HooksDir, name)
|
path := filepath.Join(p.config.HooksDir, name)
|
||||||
|
@ -178,6 +179,18 @@ func (p *plugin) registerHooks() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initialize the hooks dir watcher
|
||||||
|
if p.config.HooksWatch {
|
||||||
|
if err := p.watchHooks(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
// no need to register the vms since there are no entrypoint files anyway
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// this is safe to be shared across multiple vms
|
// this is safe to be shared across multiple vms
|
||||||
registry := new(require.Registry)
|
registry := new(require.Registry)
|
||||||
|
|
||||||
|
@ -191,6 +204,7 @@ func (p *plugin) registerHooks() error {
|
||||||
securityBinds(vm)
|
securityBinds(vm)
|
||||||
formsBinds(vm)
|
formsBinds(vm)
|
||||||
apisBinds(vm)
|
apisBinds(vm)
|
||||||
|
httpClientBinds(vm)
|
||||||
vm.Set("$app", p.app)
|
vm.Set("$app", p.app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -219,20 +233,21 @@ func (p *plugin) registerHooks() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p.app.OnTerminate().Add(func(e *core.TerminateEvent) error {
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if p.config.HooksWatch {
|
|
||||||
return p.watchHooks()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// watchHooks initializes a hooks file watcher that will restart the
|
// watchHooks initializes a hooks file watcher that will restart the
|
||||||
// application (*if possible) in case of a change in the hooks directory.
|
// application (*if possible) in case of a change in the hooks directory.
|
||||||
|
//
|
||||||
|
// This method does nothing if the hooks directory is missing.
|
||||||
func (p *plugin) watchHooks() error {
|
func (p *plugin) watchHooks() error {
|
||||||
|
if _, err := os.Stat(p.config.HooksDir); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil // no hooks dir to watch
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
watcher, err := fsnotify.NewWatcher()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
Loading…
Reference in New Issue