Added test coverage for template renderer.
This commit is contained in:
parent
6501621136
commit
388718598e
6 changed files with 183 additions and 35 deletions
|
|
@ -46,12 +46,15 @@ func (c *Controller) RenderPage(ctx echo.Context, page Page) error {
|
||||||
page.AppName = c.Container.Config.App.Name
|
page.AppName = c.Container.Config.App.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is an HTMX request
|
// Check if this is an HTMX non-boosted request which indicates that only partial
|
||||||
|
// content should be rendered
|
||||||
if page.HTMX.Request.Enabled && !page.HTMX.Request.Boosted {
|
if page.HTMX.Request.Enabled && !page.HTMX.Request.Boosted {
|
||||||
// Disable caching
|
// Parse and execute the templates only for the content portion of the page
|
||||||
page.Cache.Enabled = false
|
// The templates used for this partial request will be:
|
||||||
|
// 1. The base htmx template which omits the layout and only includes the content template
|
||||||
// Parse and execute
|
// 2. The content template specified in Page.Name
|
||||||
|
// 3. All templates within the components directory
|
||||||
|
// Also included is the function map provided by the funcmap package
|
||||||
buf, err = c.Container.TemplateRenderer.ParseAndExecute(
|
buf, err = c.Container.TemplateRenderer.ParseAndExecute(
|
||||||
"page:htmx",
|
"page:htmx",
|
||||||
page.Name,
|
page.Name,
|
||||||
|
|
@ -150,10 +153,10 @@ func (c *Controller) cachePage(ctx echo.Context, page Page, html *bytes.Buffer)
|
||||||
// Redirect redirects to a given route name with optional route parameters
|
// Redirect redirects to a given route name with optional route parameters
|
||||||
func (c *Controller) Redirect(ctx echo.Context, route string, routeParams ...interface{}) error {
|
func (c *Controller) Redirect(ctx echo.Context, route string, routeParams ...interface{}) error {
|
||||||
url := ctx.Echo().Reverse(route, routeParams)
|
url := ctx.Echo().Reverse(route, routeParams)
|
||||||
// TODO: HTMX redirect?
|
|
||||||
return ctx.Redirect(http.StatusFound, url)
|
return ctx.Redirect(http.StatusFound, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fail is a helper to fail a request by returning a 500 error and logging the error
|
||||||
func (c *Controller) Fail(ctx echo.Context, err error, log string) error {
|
func (c *Controller) Fail(ctx echo.Context, err error, log string) error {
|
||||||
ctx.Logger().Errorf("%s: %v", log, err)
|
ctx.Logger().Errorf("%s: %v", log, err)
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError)
|
return echo.NewHTTPError(http.StatusInternalServerError)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"goweb/config"
|
"goweb/config"
|
||||||
|
"goweb/htmx"
|
||||||
"goweb/middleware"
|
"goweb/middleware"
|
||||||
"goweb/services"
|
"goweb/services"
|
||||||
"goweb/tests"
|
"goweb/tests"
|
||||||
|
|
@ -109,6 +110,39 @@ func TestController_RenderPage(t *testing.T) {
|
||||||
assert.Empty(t, expectedTemplates)
|
assert.Empty(t, expectedTemplates)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("htmx rendering", func(t *testing.T) {
|
||||||
|
ctx, _, ctr, p := setup()
|
||||||
|
p.HTMX.Request.Enabled = true
|
||||||
|
p.HTMX.Response = &htmx.Response{
|
||||||
|
Trigger: "trigger",
|
||||||
|
}
|
||||||
|
err := ctr.RenderPage(ctx, p)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Check HTMX header
|
||||||
|
assert.Equal(t, "trigger", ctx.Response().Header().Get(htmx.HeaderTrigger))
|
||||||
|
|
||||||
|
// Check the template cache
|
||||||
|
parsed, err := c.TemplateRenderer.Load("page:htmx", p.Name)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Check that all expected templates were parsed.
|
||||||
|
// This includes the name, htmx and all components
|
||||||
|
expectedTemplates := make(map[string]bool)
|
||||||
|
expectedTemplates[p.Name+config.TemplateExt] = true
|
||||||
|
expectedTemplates["htmx"+config.TemplateExt] = true
|
||||||
|
components, err := ioutil.ReadDir(c.TemplateRenderer.GetTemplatesPath() + "/components")
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, f := range components {
|
||||||
|
expectedTemplates[f.Name()] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range parsed.Templates() {
|
||||||
|
delete(expectedTemplates, v.Name())
|
||||||
|
}
|
||||||
|
assert.Empty(t, expectedTemplates)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("page cache", func(t *testing.T) {
|
t.Run("page cache", func(t *testing.T) {
|
||||||
ctx, rec, ctr, p := setup()
|
ctx, rec, ctr, p := setup()
|
||||||
p.Cache.Enabled = true
|
p.Cache.Enabled = true
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
echomw "github.com/labstack/echo/v4/middleware"
|
echomw "github.com/labstack/echo/v4/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BuildRouter builds the router
|
||||||
func BuildRouter(c *services.Container) {
|
func BuildRouter(c *services.Container) {
|
||||||
// Static files with proper cache control
|
// Static files with proper cache control
|
||||||
// funcmap.File() should be used in templates to append a cache key to the URL in order to break cache
|
// funcmap.File() should be used in templates to append a cache key to the URL in order to break cache
|
||||||
|
|
@ -58,7 +59,7 @@ func BuildRouter(c *services.Container) {
|
||||||
err := Error{Controller: ctr}
|
err := Error{Controller: ctr}
|
||||||
c.Web.HTTPErrorHandler = err.Get
|
c.Web.HTTPErrorHandler = err.Get
|
||||||
|
|
||||||
// Routes
|
// Example routes
|
||||||
navRoutes(c, g, ctr)
|
navRoutes(c, g, ctr)
|
||||||
userRoutes(c, g, ctr)
|
userRoutes(c, g, ctr)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,8 +102,8 @@ func (h *httpResponse) assertStatusCode(code int) *httpResponse {
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *httpResponse) assertRedirect(t *testing.T, destination string) *httpResponse {
|
func (h *httpResponse) assertRedirect(t *testing.T, route string, params ...interface{}) *httpResponse {
|
||||||
assert.Equal(t, destination, h.Header.Get("Location"))
|
assert.Equal(t, c.Web.Reverse(route, params), h.Header.Get("Location"))
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ func NewTemplateRenderer(cfg *config.Config) *TemplateRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets the complete templates directory path
|
// Gets the complete templates directory path
|
||||||
// This is needed incase this is called from a package outside of main, such as within tests
|
// This is needed in case this is called from a package outside of main, such as within tests
|
||||||
_, b, _, _ := runtime.Caller(0)
|
_, b, _, _ := runtime.Caller(0)
|
||||||
d := path.Join(path.Dir(b))
|
d := path.Join(path.Dir(b))
|
||||||
t.templatesPath = filepath.Join(filepath.Dir(d), config.TemplateDir)
|
t.templatesPath = filepath.Join(filepath.Dir(d), config.TemplateDir)
|
||||||
|
|
@ -47,28 +47,48 @@ func NewTemplateRenderer(cfg *config.Config) *TemplateRenderer {
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TemplateRenderer) ParseAndExecute(group, id, name string, files []string, directories []string, data interface{}) (*bytes.Buffer, error) {
|
// Parse parses a set of templates and caches them for quick execution
|
||||||
var buf *bytes.Buffer
|
// If the application environment is set to local, the cache will be bypassed and templates will be
|
||||||
var err error
|
// parsed upon each request so hot-reloading is possible without restarts.
|
||||||
|
//
|
||||||
if err = t.Parse(group, id, name, files, directories); err != nil {
|
// All template files and template directories must be provided relative to the templates directory
|
||||||
return nil, err
|
// and without template extensions. Those two values can be altered via the config package.
|
||||||
}
|
//
|
||||||
if buf, err = t.Execute(group, id, name, data); err != nil {
|
// cacheGroup is used to separate templates in to groups within the cache to avoid potential conflicts
|
||||||
return nil, err
|
// with the cacheID.
|
||||||
}
|
//
|
||||||
|
// baseName is the filename of the base template without any paths or an extension.
|
||||||
return buf, nil
|
// files is a slice of all individual template files that will be included in the parse.
|
||||||
}
|
// directories is a slice of directories which all template files witin them will be included in the parse
|
||||||
|
//
|
||||||
func (t *TemplateRenderer) Parse(group, id, name string, files []string, directories []string) error {
|
// Also included will be the function map provided by the funcmap package.
|
||||||
cacheKey := t.getCacheKey(group, id)
|
//
|
||||||
|
// An example usage of this:
|
||||||
|
// t.Parse(
|
||||||
|
// "page",
|
||||||
|
// "home",
|
||||||
|
// "main",
|
||||||
|
// []string{
|
||||||
|
// "layouts/main",
|
||||||
|
// "pages/home",
|
||||||
|
// },
|
||||||
|
// []string{"components"},
|
||||||
|
//)
|
||||||
|
//
|
||||||
|
// This will perform a template parse which will:
|
||||||
|
// - Be cached using a key of "page:home"
|
||||||
|
// - Include the layouts/main.gohtml and pages/home.gohtml templates
|
||||||
|
// - Include all templates within the components directory
|
||||||
|
// - Include the function map within the funcmap package
|
||||||
|
// - Set the base template as main.gohtml
|
||||||
|
func (t *TemplateRenderer) Parse(cacheGroup, cacheID, baseName string, files []string, directories []string) error {
|
||||||
|
cacheKey := t.getCacheKey(cacheGroup, cacheID)
|
||||||
|
|
||||||
// Check if the template has not yet been parsed or if the app environment is local, so that
|
// Check if the template has not yet been parsed or if the app environment is local, so that
|
||||||
// templates reflect changes without having the restart the server
|
// templates reflect changes without having the restart the server
|
||||||
if _, err := t.Load(group, id); err != nil || t.config.App.Environment == config.EnvLocal {
|
if _, err := t.Load(cacheGroup, cacheID); err != nil || t.config.App.Environment == config.EnvLocal {
|
||||||
// Initialize the parsed template with the function map
|
// Initialize the parsed template with the function map
|
||||||
parsed := template.New(name + config.TemplateExt).
|
parsed := template.New(baseName + config.TemplateExt).
|
||||||
Funcs(t.funcMap)
|
Funcs(t.funcMap)
|
||||||
|
|
||||||
// Parse all files provided
|
// Parse all files provided
|
||||||
|
|
@ -99,14 +119,16 @@ func (t *TemplateRenderer) Parse(group, id, name string, files []string, directo
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TemplateRenderer) Execute(group, id, name string, data interface{}) (*bytes.Buffer, error) {
|
// Execute executes a cached template with the data provided
|
||||||
tmpl, err := t.Load(group, id)
|
// See Parse() for an explanation of the parameters
|
||||||
|
func (t *TemplateRenderer) Execute(cacheGroup, cacheID, baseName string, data interface{}) (*bytes.Buffer, error) {
|
||||||
|
tmpl, err := t.Load(cacheGroup, cacheID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
err = tmpl.ExecuteTemplate(buf, name+config.TemplateExt, data)
|
err = tmpl.ExecuteTemplate(buf, baseName+config.TemplateExt, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -114,8 +136,24 @@ func (t *TemplateRenderer) Execute(group, id, name string, data interface{}) (*b
|
||||||
return buf, nil
|
return buf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TemplateRenderer) Load(group, id string) (*template.Template, error) {
|
// ParseAndExecute is a wrapper around Parse() and Execute()
|
||||||
load, ok := t.templateCache.Load(t.getCacheKey(group, id))
|
func (t *TemplateRenderer) ParseAndExecute(cacheGroup, cacheID, baseName string, files []string, directories []string, data interface{}) (*bytes.Buffer, error) {
|
||||||
|
var buf *bytes.Buffer
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if err = t.Parse(cacheGroup, cacheID, baseName, files, directories); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if buf, err = t.Execute(cacheGroup, cacheID, baseName, data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load loads a template from the cache
|
||||||
|
func (t *TemplateRenderer) Load(cacheGroup, cacheID string) (*template.Template, error) {
|
||||||
|
load, ok := t.templateCache.Load(t.getCacheKey(cacheGroup, cacheID))
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("uncached page template requested")
|
return nil, errors.New("uncached page template requested")
|
||||||
}
|
}
|
||||||
|
|
@ -128,10 +166,12 @@ func (t *TemplateRenderer) Load(group, id string) (*template.Template, error) {
|
||||||
return tmpl, nil
|
return tmpl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTemplatesPath gets the complete path to the templates directory
|
||||||
func (t *TemplateRenderer) GetTemplatesPath() string {
|
func (t *TemplateRenderer) GetTemplatesPath() string {
|
||||||
return t.templatesPath
|
return t.templatesPath
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TemplateRenderer) getCacheKey(group, id string) string {
|
// getCacheKey gets a cache key for a given group and ID
|
||||||
return fmt.Sprintf("%s:%s", group, id)
|
func (t *TemplateRenderer) getCacheKey(cacheGroup, cacheID string) string {
|
||||||
|
return fmt.Sprintf("%s:%s", cacheGroup, cacheID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
70
services/template_renderer_test.go
Normal file
70
services/template_renderer_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"goweb/config"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTemplateRenderer(t *testing.T) {
|
||||||
|
group := "test"
|
||||||
|
id := "parse"
|
||||||
|
|
||||||
|
// Should not exist yet
|
||||||
|
_, err := c.TemplateRenderer.Load(group, id)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Parse in to the cache
|
||||||
|
err = c.TemplateRenderer.Parse(
|
||||||
|
group,
|
||||||
|
id,
|
||||||
|
"htmx",
|
||||||
|
[]string{"htmx", "pages/error"},
|
||||||
|
[]string{"components"},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should exist now
|
||||||
|
parsed, err := c.TemplateRenderer.Load(group, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Check that all expected templates are included
|
||||||
|
expectedTemplates := make(map[string]bool)
|
||||||
|
expectedTemplates["htmx"+config.TemplateExt] = true
|
||||||
|
expectedTemplates["error"+config.TemplateExt] = true
|
||||||
|
components, err := ioutil.ReadDir(c.TemplateRenderer.GetTemplatesPath() + "/components")
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, f := range components {
|
||||||
|
expectedTemplates[f.Name()] = true
|
||||||
|
}
|
||||||
|
for _, v := range parsed.Templates() {
|
||||||
|
delete(expectedTemplates, v.Name())
|
||||||
|
}
|
||||||
|
assert.Empty(t, expectedTemplates)
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
StatusCode int
|
||||||
|
}{
|
||||||
|
StatusCode: 500,
|
||||||
|
}
|
||||||
|
buf, err := c.TemplateRenderer.Execute(group, id, "htmx", data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, buf)
|
||||||
|
assert.Contains(t, buf.String(), "Please try again")
|
||||||
|
|
||||||
|
buf, err = c.TemplateRenderer.ParseAndExecute(
|
||||||
|
group,
|
||||||
|
id,
|
||||||
|
"htmx",
|
||||||
|
[]string{"htmx", "pages/error"},
|
||||||
|
[]string{"components"},
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, buf)
|
||||||
|
assert.Contains(t, buf.String(), "Please try again")
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue