diff --git a/config/types.go b/config/types.go index e97de95572..e7fd6f481f 100644 --- a/config/types.go +++ b/config/types.go @@ -2,20 +2,26 @@ package config // Config 象传应用引擎配置 type Config struct { - Mode string `json:"mode,omitempty" env:"YAO_ENV" envDefault:"production"` // 象传引擎启动模式 production/development - Root string `json:"root,omitempty" env:"YAO_ROOT" envDefault:"."` // 应用根目录 - DataRoot string `json:"data_root,omitempty" env:"YAO_DATA_ROOT" envDefault:""` // DATA PATH - Host string `json:"host,omitempty" env:"YAO_HOST" envDefault:"0.0.0.0"` // 服务监听地址 - Port int `json:"port,omitempty" env:"YAO_PORT" envDefault:"5099"` // 服务监听端口 - Cert string `json:"cert,omitempty" env:"YAO_CERT"` // HTTPS 证书文件地址 - Key string `json:"key,omitempty" env:"YAO_KEY"` // HTTPS 证书密钥地址 - Log string `json:"log,omitempty" env:"YAO_LOG"` // 服务日志地址 - LogMode string `json:"log_mode,omitempty" env:"YAO_LOG_MODE" envDefault:"TEXT"` // 服务日志模式 JSON|TEXT - // Session string `json:"session,omitempty" env:"YAO_SESSION" envDefault:"memory"` // 用户会话模式 memory|redis|database - JWTSecret string `json:"jwt_secret,omitempty" env:"YAO_JWT_SECRET"` // JWT 密钥 - DB DBConfig `json:"db,omitempty"` // 数据库配置 - AllowFrom []string `json:"allowfrom,omitempty" envSeparator:"|" env:"YAO_ALLOW_FROM" ` // Domain list the separator is | - Session SessionConfig `json:"session,omitempty"` + Mode string `json:"mode,omitempty" env:"YAO_ENV" envDefault:"production"` // 象传引擎启动模式 production/development + Root string `json:"root,omitempty" env:"YAO_ROOT" envDefault:"."` // 应用根目录 + DataRoot string `json:"data_root,omitempty" env:"YAO_DATA_ROOT" envDefault:""` // DATA PATH + Host string `json:"host,omitempty" env:"YAO_HOST" envDefault:"0.0.0.0"` // 服务监听地址 + Port int `json:"port,omitempty" env:"YAO_PORT" envDefault:"5099"` // 服务监听端口 + Cert string `json:"cert,omitempty" env:"YAO_CERT"` // HTTPS 证书文件地址 + Key string `json:"key,omitempty" env:"YAO_KEY"` // HTTPS 证书密钥地址 + Log string `json:"log,omitempty" env:"YAO_LOG"` // 服务日志地址 + LogMode string `json:"log_mode,omitempty" env:"YAO_LOG_MODE" envDefault:"TEXT"` // 服务日志模式 JSON|TEXT + JWTSecret string `json:"jwt_secret,omitempty" env:"YAO_JWT_SECRET"` // JWT 密钥 + DB DBConfig `json:"db,omitempty"` // 数据库配置 + AllowFrom []string `json:"allowfrom,omitempty" envSeparator:"|" env:"YAO_ALLOW_FROM"` // Domain list the separator is | + Session SessionConfig `json:"session,omitempty"` // Session Config + Studio StudioConfig `json:"studio,omitempty"` // Studio config +} + +// StudioConfig the studio config +type StudioConfig struct { + Port int `json:"studio_port,omitempty" env:"YAO_STUDIO_PORT" envDefault:"5077"` // Studio port + Secret int `json:"studio_secret,omitempty" env:"YAO_STUDIO_SECRET"` // Studio Secret, if does not set, auto-generate a secret } // DBConfig 数据库配置 diff --git a/studio/middleware.go b/studio/middleware.go new file mode 100644 index 0000000000..411855f7df --- /dev/null +++ b/studio/middleware.go @@ -0,0 +1,41 @@ +package studio + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/kun/exception" + "github.com/yaoapp/xun" +) + +func hdRecovered(c *gin.Context, recovered interface{}) { + + var code = http.StatusInternalServerError + + if err, ok := recovered.(string); ok { + c.JSON(code, xun.R{ + "code": code, + "message": fmt.Sprintf("%s", err), + }) + } else if err, ok := recovered.(exception.Exception); ok { + code = err.Code + c.JSON(code, xun.R{ + "code": code, + "message": err.Message, + }) + } else if err, ok := recovered.(*exception.Exception); ok { + code = err.Code + c.JSON(code, xun.R{ + "code": code, + "message": err.Message, + }) + } else { + c.JSON(code, xun.R{ + "code": code, + "message": fmt.Sprintf("%v", recovered), + }) + } + + c.AbortWithStatus(code) +} diff --git a/studio/router.go b/studio/router.go new file mode 100644 index 0000000000..caccfcb784 --- /dev/null +++ b/studio/router.go @@ -0,0 +1,183 @@ +package studio + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/gin-gonic/gin" + jsoniter "github.com/json-iterator/go" + "github.com/yaoapp/gou" +) + +// Serve start the api server +func setRouter(router *gin.Engine) { + + router.Use(gin.CustomRecovery(hdRecovered)) + + // DSL ReadDir, ReadFile + router.GET("/dsl/:method", func(c *gin.Context) { + method := strings.ToLower(c.Param("method")) + switch method { + + case "readfile": + name := c.Query("name") + if name == "" { + throw(c, 400, "file name is required") + return + } + + data, err := dfs.ReadFile(name) + if err != nil { + throw(c, 500, err.Error()) + return + } + + res := map[string]interface{}{} + err = jsoniter.Unmarshal(data, &res) + if err != nil { + throw(c, 500, err.Error()) + return + } + c.JSON(200, res) + c.Done() + break + + case "readdir": + name := c.Query("name") + if name == "" { + throw(c, 400, "dir name is required") + return + } + + recursive := false + if c.Query("recursive") == "1" || strings.ToLower(c.Query("recursive")) == "true" { + recursive = true + } + data, err := dfs.ReadDir(name, recursive) + if err != nil { + throw(c, 500, err.Error()) + return + } + c.JSON(200, data) + c.Done() + break + } + }) + + // DSL WriteFile, Mkdir, MkdirAll ... + router.POST("/dsl/:method", func(c *gin.Context) { + + method := strings.ToLower(c.Param("method")) + switch method { + case "writefile": + name := c.Query("name") + if name == "" { + throw(c, 400, "dir name is required") + return + } + + payload, err := io.ReadAll(c.Request.Body) + if err != nil { + throw(c, 500, err.Error()) + return + } + + if payload == nil || len(payload) == 0 { + throw(c, 500, "file content is required") + return + } + + length, err := dfs.WriteFile(name, payload, 0644) + if err != nil { + throw(c, 500, err.Error()) + return + } + + c.JSON(200, length) + c.Done() + break + + case "mkdir": + name := c.Query("name") + if name == "" { + throw(c, 400, "dir name is required") + return + } + + err := dfs.Mkdir(name, int(os.ModePerm)) + if err != nil { + throw(c, 500, err.Error()) + return + } + c.Status(200) + c.Done() + break + + case "mkdirall": + name := c.Query("name") + if name == "" { + throw(c, 400, "dir name is required") + return + } + + err := dfs.MkdirAll(name, int(os.ModePerm)) + if err != nil { + throw(c, 500, err.Error()) + return + } + c.Status(200) + c.Done() + break + } + + }) + + // Cloud Functions + router.POST("/service/:name", func(c *gin.Context) { + + name := c.Param("name") + if name == "" { + throw(c, 400, "service name is required") + return + } + + service := fmt.Sprintf("__yao.studio.%s", c.Param("name")) + + payload, err := io.ReadAll(c.Request.Body) + if err != nil { + throw(c, 500, err.Error()) + return + } + + if payload == nil || len(payload) > 0 { + throw(c, 400, "file content is required") + return + } + + var call cloudCall + err = jsoniter.Unmarshal(payload, &call) + if err != nil { + throw(c, 500, err.Error()) + return + } + + res, err := gou.Yao.Engine.Call(map[string]interface{}{}, service, call.Method, call.Args...) + if err != nil { + throw(c, 500, err.Error()) + return + } + + c.JSON(200, res) + c.Done() + }) +} + +func throw(c *gin.Context, code int, message string) { + c.JSON(code, map[string]interface{}{ + "message": message, + "code": code, + }) + c.Done() +} diff --git a/studio/studio.go b/studio/studio.go index 6c2ab6ef8b..513111c0de 100644 --- a/studio/studio.go +++ b/studio/studio.go @@ -1,3 +1,148 @@ package studio -// Yao Studio +import ( + "fmt" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/gou" + "github.com/yaoapp/gou/fs" + "github.com/yaoapp/gou/fs/dsl" + "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/share" +) + +var shutdownSignal = make(chan bool, 1) +var dfs fs.FileSystem +var scripts = map[string][]byte{} + +type cloudCall struct { + Method string `json:"method"` + Args []interface{} `json:"args,omitempty"` +} + +// Start start the studio api server +func Start(cfg config.Config) (err error) { + + // recive interrupt signal + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) + + errCh := make(chan error, 1) + + // Set router + router := gin.Default() + setRouter(router) + + // Server setting + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Studio.Port) + srv := &http.Server{ + Addr: addr, + Handler: router, + } + + // Listen + l, err := net.Listen("tcp", addr) + if err != nil { + return err + } + + defer func() { + log.Info("[Studio] %s Close Serve", addr) + err = srv.Close() + if err != nil { + log.Error("[Studio] Close Serve Error (%v)", err) + } + }() + + // start serve + go func() { + log.Info("[Studio] Starting: %s", addr) + if err := srv.Serve(l); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + select { + + case <-shutdownSignal: + log.Info("[Studio] %s Exit (Manual)", addr) + return err + + case <-interrupt: + log.Info("[Studio] %s Exit (Interrupt) ", addr) + return err + + case err := <-errCh: + log.Error("[Studio] %s Error (%v)", addr, err) + return err + } +} + +// Stop stop the studio api server +func Stop() { + shutdownSignal <- true +} + +// Load studio config +func Load(cfg config.Config) error { + err := loadDSL(cfg) + if err != nil { + return err + } + return loadScripts(cfg) +} + +func loadDSL(cfg config.Config) error { + + root, err := filepath.Abs(cfg.Root) + if err != nil { + return err + } + + scriptRoot := filepath.Join(root, "scripts") + dataRoot := filepath.Join(root, "data") + dslDenyList := []string{scriptRoot, dataRoot} + dfs = dsl.New(root).DenyAbs(dslDenyList...) + return nil +} + +func loadScripts(cfg config.Config) error { + root, err := filepath.Abs(cfg.Root) + if err != nil { + return err + } + + studioRoot := filepath.Join(root, "studio") + return loadScriptFrom(studioRoot) +} + +// Load script From dir +func loadScriptFrom(dir string) error { + + if share.DirNotExists(dir) { + log.Warn("[Studio] Load %s does not exists", dir) + return nil + } + + messages := []string{} + err := share.Walk(dir, ".js", func(root, filename string) { + name := fmt.Sprintf("__yao.studio.%s", share.SpecName(root, filename)) + err := gou.Yao.Load(filename, name) + if err != nil { + messages = append(messages, err.Error()) + } + }) + + if len(messages) > 0 { + return fmt.Errorf("[Studio] Load %s", strings.Join(messages, ";")) + } + return err +} diff --git a/studio/studio_test.go b/studio/studio_test.go new file mode 100644 index 0000000000..4c9c7a95bb --- /dev/null +++ b/studio/studio_test.go @@ -0,0 +1,78 @@ +package studio + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/gou" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/network" +) + +func TestLoad(t *testing.T) { + err := Load(config.Conf) + if err != nil { + t.Fatal(err) + } + assert.NotNil(t, dfs) + + res, err := gou.Yao.Engine.Call(map[string]interface{}{}, "__yao.studio.table", "Ping") + assert.Nil(t, err) + assert.Equal(t, "PONG", res) +} + +func TestStartStop(t *testing.T) { + var err error + go func() { err = Start(config.Conf) }() + if err != nil { + t.Fatal(err) + } + + time.Sleep(500 * time.Millisecond) + Stop() + time.Sleep(100 * time.Millisecond) +} + +func TestStartStopError(t *testing.T) { + var err error + go func() { err = Start(config.Conf) }() + if err != nil { + t.Fatal(err) + } + + time.Sleep(500 * time.Millisecond) + go func() { err = Start(config.Conf) }() + time.Sleep(100 * time.Millisecond) + assert.NotNil(t, err) + + Stop() + time.Sleep(100 * time.Millisecond) +} + +func TestGetAPI(t *testing.T) { + + Load(config.Conf) + + var err error + go func() { err = Start(config.Conf) }() + if err != nil { + t.Fatal(err) + } + defer Stop() + time.Sleep(500 * time.Millisecond) + + url := fmt.Sprintf("http://127.0.0.1:%d/dsl/readfile?name=/models/user.json", config.Conf.Studio.Port) + res := network.RequestGet(url, nil, nil) + assert.Equal(t, "用户", res.Data.(map[string]interface{})["name"]) + + url = fmt.Sprintf("http://127.0.0.1:%d/dsl/readdir?name=/models", config.Conf.Studio.Port) + res = network.RequestGet(url, nil, nil) + assert.Equal(t, 11, len(res.Data.([]interface{}))) + + url = fmt.Sprintf("http://127.0.0.1:%d/dsl/readdir?name=/models&recursive=1", config.Conf.Studio.Port) + res = network.RequestGet(url, nil, nil) + assert.Equal(t, 12, len(res.Data.([]interface{}))) + +} diff --git a/table/process.go b/table/process.go index cab31d053e..848c590878 100644 --- a/table/process.go +++ b/table/process.go @@ -385,17 +385,22 @@ func ProcessExport(process *gou.Process) interface{} { WithSID(process.Sid). Run() - // After Hook - response = table.After(table.Hooks.AfterSearch, response, []interface{}{param, page, pagesize}, process.Sid) - if debug { bytes, _ := jsoniter.Marshal(response) log.Info("[Export] %s %d %d Prepare: %s", filename, page, pagesize, string(bytes)) } - res, ok := response.(map[string]interface{}) + // After Hook + respAfterHook := table.After(table.Hooks.AfterSearch, response, []interface{}{param, page, pagesize}, process.Sid) + + if debug { + bytes, _ := jsoniter.Marshal(respAfterHook) + log.Info("[Export] %s %d %d Prepare After: %s", filename, page, pagesize, string(bytes)) + } + + res, ok := respAfterHook.(map[string]interface{}) if !ok { - res, ok = response.(maps.MapStrAny) + res, ok = respAfterHook.(maps.MapStrAny) if !ok { page = -1 continue diff --git a/tests/studio/table.js b/tests/studio/table.js new file mode 100644 index 0000000000..2cda5ccc79 --- /dev/null +++ b/tests/studio/table.js @@ -0,0 +1,33 @@ +/** + * Create table + * @param {*} name + */ +function Make(name) { + var fs = new FS("dsl"); + var model = GetModel(name); + var table = JSON.stringify({ + name: `${model.name} Admin`, + actions: { bind: { model: name } }, + }); + fs.WriteFile(`/tables/auto/${name}.tab.json`, table); +} + +/** + * Get Model + * @param {*} name + * @returns + */ +function Model(name) { + var fs = new FS("dsl"); + var file = `/models/${name}.mod.json`; + var data = fs.ReadFile(file); + return JSON.parse(data); +} + +/** + * for unit tests + * @returns + */ +function Ping() { + return "PONG"; +}