From c450072f4a490355dffc31f5cc36036a2383b1c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=2E=20Efe=20=C3=87etin?= Date: Sun, 6 Feb 2022 16:58:45 +0300 Subject: [PATCH] :bug: fix: mounted app views (#1749) * Fix mounted app views. * Cleaner structure. Co-authored-by: RW * remove unnecessary lines. * Add test case for group-with-mount, remove unnecessary lines. Co-authored-by: RW --- .github/testdata/template/hello_world.gohtml | 1 + app.go | 34 +-- ctx.go | 31 ++- ctx_test.go | 54 +++++ group.go | 10 +- internal/template/html/html.go | 214 +++++++++++++++++++ internal/template/utils/utils.go | 110 ++++++++++ 7 files changed, 422 insertions(+), 32 deletions(-) create mode 100644 .github/testdata/template/hello_world.gohtml create mode 100644 internal/template/html/html.go create mode 100644 internal/template/utils/utils.go diff --git a/.github/testdata/template/hello_world.gohtml b/.github/testdata/template/hello_world.gohtml new file mode 100644 index 00000000..d47d8c59 --- /dev/null +++ b/.github/testdata/template/hello_world.gohtml @@ -0,0 +1 @@ +

Hello {{ .Name }}!

\ No newline at end of file diff --git a/app.go b/app.go index 17ca68d1..fcc84eff 100644 --- a/app.go +++ b/app.go @@ -111,8 +111,9 @@ type App struct { getBytes func(s string) (b []byte) // Converts byte slice to a string getString func(b []byte) string - // mount prefix -> error handler - errorHandlers map[string]ErrorHandler + + // Mounted and main apps + appList map[string]*App } // Config is a struct holding the server settings. @@ -460,10 +461,10 @@ func New(config ...Config) *App { }, }, // Create config - config: Config{}, - getBytes: utils.UnsafeBytes, - getString: utils.UnsafeString, - errorHandlers: make(map[string]ErrorHandler), + config: Config{}, + getBytes: utils.UnsafeBytes, + getString: utils.UnsafeString, + appList: make(map[string]*App), } // Override config if provided if len(config) > 0 { @@ -515,6 +516,9 @@ func New(config ...Config) *App { app.handleTrustedProxy(ipAddress) } + // Init appList + app.appList[""] = app + // Init app app.init() @@ -544,6 +548,7 @@ func (app *App) handleTrustedProxy(ipAddress string) { // to be invoked on errors that happen within the prefix route. func (app *App) Mount(prefix string, fiber *App) Router { stack := fiber.Stack() + prefix = strings.TrimRight(prefix, "/") for m := range stack { for r := range stack[m] { route := app.copyRoute(stack[m][r]) @@ -551,13 +556,10 @@ func (app *App) Mount(prefix string, fiber *App) Router { } } - // Save the fiber's error handler and its sub apps - prefix = strings.TrimRight(prefix, "/") - if fiber.config.ErrorHandler != nil { - app.errorHandlers[prefix] = fiber.config.ErrorHandler - } - for mountedPrefixes, errHandler := range fiber.errorHandlers { - app.errorHandlers[prefix+mountedPrefixes] = errHandler + // Support for configs of mounted-apps and sub-mounted-apps + for mountedPrefixes, subApp := range fiber.appList { + app.appList[prefix+mountedPrefixes] = subApp + subApp.init() } atomic.AddUint32(&app.handlersCount, fiber.handlersCount) @@ -1002,11 +1004,11 @@ func (app *App) ErrorHandler(ctx *Ctx, err error) error { mountedPrefixParts int ) - for prefix, errHandler := range app.errorHandlers { - if strings.HasPrefix(ctx.path, prefix) { + for prefix, subApp := range app.appList { + if strings.HasPrefix(ctx.path, prefix) && prefix != "" { parts := len(strings.Split(prefix, "/")) if mountedPrefixParts <= parts { - mountedErrHandler = errHandler + mountedErrHandler = subApp.config.ErrorHandler mountedPrefixParts = parts } } diff --git a/ctx.go b/ctx.go index ab1a9eaa..33525341 100644 --- a/ctx.go +++ b/ctx.go @@ -1082,18 +1082,28 @@ func (c *Ctx) Render(name string, bind interface{}, layouts ...string) error { } - if c.app.config.Views != nil { - // Render template based on global layout if exists - if len(layouts) == 0 && c.app.config.ViewsLayout != "" { - layouts = []string{ - c.app.config.ViewsLayout, + rendered := false + for prefix, app := range c.app.appList { + if prefix == "" || strings.Contains(c.OriginalURL(), prefix) { + if len(layouts) == 0 && app.config.ViewsLayout != "" { + layouts = []string{ + app.config.ViewsLayout, + } + } + + // Render template from Views + if app.config.Views != nil { + if err := app.config.Views.Render(buf, name, bind, layouts...); err != nil { + return err + } + + rendered = true + break } } - // Render template from Views - if err := c.app.config.Views.Render(buf, name, bind, layouts...); err != nil { - return err - } - } else { + } + + if !rendered { // Render raw template using 'name' as filepath if no engine is set var tmpl *template.Template if _, err = readContent(buf, name); err != nil { @@ -1109,6 +1119,7 @@ func (c *Ctx) Render(name string, bind interface{}, layouts ...string) error { return err } } + // Set Content-Type to text/html c.fasthttp.Response.Header.SetContentType(MIMETextHTMLCharsetUTF8) // Set rendered template to body diff --git a/ctx_test.go b/ctx_test.go index 1e857143..26eaf9bc 100644 --- a/ctx_test.go +++ b/ctx_test.go @@ -28,6 +28,7 @@ import ( "github.com/gofiber/fiber/v2/internal/bytebufferpool" "github.com/gofiber/fiber/v2/internal/storage/memory" + "github.com/gofiber/fiber/v2/internal/template/html" "github.com/gofiber/fiber/v2/utils" "github.com/valyala/fasthttp" ) @@ -2049,6 +2050,59 @@ func Test_Ctx_Render(t *testing.T) { err = c.Render("./.github/testdata/template-invalid.html", nil) utils.AssertEqual(t, false, err == nil) } + +// go test -run Test_Ctx_Render_Mount +func Test_Ctx_Render_Mount(t *testing.T) { + t.Parallel() + + sub := New(Config{ + Views: html.New("./.github/testdata/template", ".gohtml"), + }) + + sub.Get("/:name", func(ctx *Ctx) error { + return ctx.Render("hello_world", Map{ + "Name": ctx.Params("name"), + }) + }) + + app := New() + app.Mount("/hello", sub) + + resp, err := app.Test(httptest.NewRequest(MethodGet, "/hello/a", nil)) + utils.AssertEqual(t, StatusOK, resp.StatusCode, "Status code") + utils.AssertEqual(t, nil, err, "app.Test(req)") + + body, err := ioutil.ReadAll(resp.Body) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, "

Hello a!

", string(body)) +} + +func Test_Ctx_Render_MountGroup(t *testing.T) { + t.Parallel() + + micro := New(Config{ + Views: html.New("./.github/testdata/template", ".gohtml"), + }) + + micro.Get("/doe", func(c *Ctx) error { + return c.Render("hello_world", Map{ + "Name": "doe", + }) + }) + + app := New() + v1 := app.Group("/v1") + v1.Mount("/john", micro) + + resp, err := app.Test(httptest.NewRequest(MethodGet, "/v1/john/doe", nil)) + utils.AssertEqual(t, nil, err, "app.Test(req)") + utils.AssertEqual(t, 200, resp.StatusCode, "Status code") + + body, err := ioutil.ReadAll(resp.Body) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, "

Hello doe!

", string(body)) +} + func Test_Ctx_RenderWithoutLocals(t *testing.T) { t.Parallel() app := New(Config{ diff --git a/group.go b/group.go index b3816c91..0e4efaf2 100644 --- a/group.go +++ b/group.go @@ -32,13 +32,11 @@ func (grp *Group) Mount(prefix string, fiber *App) Router { } } - // Save the fiber's error handler and its sub apps + // Support for configs of mounted-apps and sub-mounted-apps groupPath = strings.TrimRight(groupPath, "/") - if fiber.config.ErrorHandler != nil { - grp.app.errorHandlers[groupPath] = fiber.config.ErrorHandler - } - for mountedPrefixes, errHandler := range fiber.errorHandlers { - grp.app.errorHandlers[groupPath+mountedPrefixes] = errHandler + for mountedPrefixes, subApp := range fiber.appList { + grp.app.appList[groupPath+mountedPrefixes] = subApp + subApp.init() } atomic.AddUint32(&grp.app.handlersCount, fiber.handlersCount) diff --git a/internal/template/html/html.go b/internal/template/html/html.go new file mode 100644 index 00000000..4ed0ff2a --- /dev/null +++ b/internal/template/html/html.go @@ -0,0 +1,214 @@ +package html + +import ( + "fmt" + "html/template" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/gofiber/fiber/v2/internal/template/utils" +) + +// Engine struct +type Engine struct { + // delimiters + left string + right string + // views folder + directory string + // http.FileSystem supports embedded files + fileSystem http.FileSystem + // views extension + extension string + // layout variable name that incapsulates the template + layout string + // determines if the engine parsed all templates + loaded bool + // reload on each render + reload bool + // debug prints the parsed templates + debug bool + // lock for funcmap and templates + mutex sync.RWMutex + // template funcmap + funcmap map[string]interface{} + // templates + Templates *template.Template +} + +// New returns a HTML render engine for Fiber +func New(directory, extension string) *Engine { + engine := &Engine{ + left: "{{", + right: "}}", + directory: directory, + extension: extension, + layout: "embed", + funcmap: make(map[string]interface{}), + } + engine.AddFunc(engine.layout, func() error { + return fmt.Errorf("layout called unexpectedly.") + }) + return engine +} + +//NewFileSystem ... +func NewFileSystem(fs http.FileSystem, extension string) *Engine { + engine := &Engine{ + left: "{{", + right: "}}", + directory: "/", + fileSystem: fs, + extension: extension, + layout: "embed", + funcmap: make(map[string]interface{}), + } + engine.AddFunc(engine.layout, func() error { + return fmt.Errorf("layout called unexpectedly.") + }) + return engine +} + +// Layout defines the variable name that will incapsulate the template +func (e *Engine) Layout(key string) *Engine { + e.layout = key + return e +} + +// Delims sets the action delimiters to the specified strings, to be used in +// templates. An empty delimiter stands for the +// corresponding default: {{ or }}. +func (e *Engine) Delims(left, right string) *Engine { + e.left, e.right = left, right + return e +} + +// AddFunc adds the function to the template's function map. +// It is legal to overwrite elements of the default actions +func (e *Engine) AddFunc(name string, fn interface{}) *Engine { + e.mutex.Lock() + e.funcmap[name] = fn + e.mutex.Unlock() + return e +} + +// Reload if set to true the templates are reloading on each render, +// use it when you're in development and you don't want to restart +// the application when you edit a template file. +func (e *Engine) Reload(enabled bool) *Engine { + e.reload = enabled + return e +} + +// Debug will print the parsed templates when Load is triggered. +func (e *Engine) Debug(enabled bool) *Engine { + e.debug = enabled + return e +} + +// Parse is deprecated, please use Load() instead +func (e *Engine) Parse() error { + fmt.Println("Parse() is deprecated, please use Load() instead.") + return e.Load() +} + +// Load parses the templates to the engine. +func (e *Engine) Load() error { + if e.loaded { + return nil + } + // race safe + e.mutex.Lock() + defer e.mutex.Unlock() + e.Templates = template.New(e.directory) + + // Set template settings + e.Templates.Delims(e.left, e.right) + e.Templates.Funcs(e.funcmap) + + walkFn := func(path string, info os.FileInfo, err error) error { + // Return error if exist + if err != nil { + return err + } + // Skip file if it's a directory or has no file info + if info == nil || info.IsDir() { + return nil + } + // Skip file if it does not equal the given template extension + if len(e.extension) >= len(path) || path[len(path)-len(e.extension):] != e.extension { + return nil + } + // Get the relative file path + // ./views/html/index.tmpl -> index.tmpl + rel, err := filepath.Rel(e.directory, path) + if err != nil { + return err + } + // Reverse slashes '\' -> '/' and + // partials\footer.tmpl -> partials/footer.tmpl + name := filepath.ToSlash(rel) + // Remove ext from name 'index.tmpl' -> 'index' + name = strings.TrimSuffix(name, e.extension) + // name = strings.Replace(name, e.extension, "", -1) + // Read the file + // #gosec G304 + buf, err := utils.ReadFile(path, e.fileSystem) + if err != nil { + return err + } + // Create new template associated with the current one + // This enable use to invoke other templates {{ template .. }} + _, err = e.Templates.New(name).Parse(string(buf)) + if err != nil { + return err + } + // Debugging + if e.debug { + fmt.Printf("views: parsed template: %s\n", name) + } + return err + } + // notify engine that we parsed all templates + e.loaded = true + if e.fileSystem != nil { + return utils.Walk(e.fileSystem, e.directory, walkFn) + } + return filepath.Walk(e.directory, walkFn) +} + +// Render will execute the template name along with the given values. +func (e *Engine) Render(out io.Writer, template string, binding interface{}, layout ...string) error { + if !e.loaded || e.reload { + if e.reload { + e.loaded = false + } + if err := e.Load(); err != nil { + return err + } + } + + tmpl := e.Templates.Lookup(template) + if tmpl == nil { + return fmt.Errorf("render: template %s does not exist", template) + } + if len(layout) > 0 && layout[0] != "" { + lay := e.Templates.Lookup(layout[0]) + if lay == nil { + return fmt.Errorf("render: layout %s does not exist", layout[0]) + } + e.mutex.Lock() + defer e.mutex.Unlock() + lay.Funcs(map[string]interface{}{ + e.layout: func() error { + return tmpl.Execute(out, binding) + }, + }) + return lay.Execute(out, binding) + } + return tmpl.Execute(out, binding) +} diff --git a/internal/template/utils/utils.go b/internal/template/utils/utils.go new file mode 100644 index 00000000..a19e6d6e --- /dev/null +++ b/internal/template/utils/utils.go @@ -0,0 +1,110 @@ +package utils + +import ( + "io/ioutil" + "net/http" + "os" + pathpkg "path" + "path/filepath" + "sort" +) + +// Walk walks the filesystem rooted at root, calling walkFn for each file or +// directory in the filesystem, including root. All errors that arise visiting files +// and directories are filtered by walkFn. The files are walked in lexical +// order. +func Walk(fs http.FileSystem, root string, walkFn filepath.WalkFunc) error { + info, err := stat(fs, root) + if err != nil { + return walkFn(root, nil, err) + } + return walk(fs, root, info, walkFn) +} + +// #nosec G304 +// ReadFile returns the raw content of a file +func ReadFile(path string, fs http.FileSystem) ([]byte, error) { + if fs != nil { + file, err := fs.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + return ioutil.ReadAll(file) + } + return ioutil.ReadFile(path) +} + +// readDirNames reads the directory named by dirname and returns +// a sorted list of directory entries. +func readDirNames(fs http.FileSystem, dirname string) ([]string, error) { + fis, err := readDir(fs, dirname) + if err != nil { + return nil, err + } + names := make([]string, len(fis)) + for i := range fis { + names[i] = fis[i].Name() + } + sort.Strings(names) + return names, nil +} + +// walk recursively descends path, calling walkFn. +func walk(fs http.FileSystem, path string, info os.FileInfo, walkFn filepath.WalkFunc) error { + err := walkFn(path, info, nil) + if err != nil { + if info.IsDir() && err == filepath.SkipDir { + return nil + } + return err + } + + if !info.IsDir() { + return nil + } + + names, err := readDirNames(fs, path) + if err != nil { + return walkFn(path, info, err) + } + + for _, name := range names { + filename := pathpkg.Join(path, name) + fileInfo, err := stat(fs, filename) + if err != nil { + if err := walkFn(filename, fileInfo, err); err != nil && err != filepath.SkipDir { + return err + } + } else { + err = walk(fs, filename, fileInfo, walkFn) + if err != nil { + if !fileInfo.IsDir() || err != filepath.SkipDir { + return err + } + } + } + } + return nil +} + +// readDir reads the contents of the directory associated with file and +// returns a slice of FileInfo values in directory order. +func readDir(fs http.FileSystem, name string) ([]os.FileInfo, error) { + f, err := fs.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return f.Readdir(0) +} + +// stat returns the FileInfo structure describing file. +func stat(fs http.FileSystem, name string) (os.FileInfo, error) { + f, err := fs.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return f.Stat() +}