From a382f1da69786fca5e1eeb33f6e54bb609d5c978 Mon Sep 17 00:00:00 2001 From: Tony Grosinger Date: Thu, 25 Jul 2024 20:35:08 -0700 Subject: [PATCH] wip --- pkg/handlers/router.go | 1 - pkg/middleware/cache.go | 52 ---------- pkg/middleware/cache_test.go | 42 -------- pkg/page/page.go | 5 - pkg/services/template_renderer.go | 134 ------------------------- pkg/services/template_renderer_test.go | 124 ++++------------------- 6 files changed, 21 insertions(+), 337 deletions(-) diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go index 996cdc2..edba374 100644 --- a/pkg/handlers/router.go +++ b/pkg/handlers/router.go @@ -42,7 +42,6 @@ func BuildRouter(c *services.Container) error { }), middleware.Session(sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))), middleware.LoadAuthenticatedUser(c.Auth), - middleware.ServeCachedPage(c.TemplateRenderer), echomw.CSRFWithConfig(echomw.CSRFConfig{ TokenLookup: "form:csrf", }), diff --git a/pkg/middleware/cache.go b/pkg/middleware/cache.go index cf28169..b353a2b 100644 --- a/pkg/middleware/cache.go +++ b/pkg/middleware/cache.go @@ -1,64 +1,12 @@ package middleware import ( - "errors" "fmt" - "net/http" "time" "github.com/labstack/echo/v4" - - "git.grosinger.net/tgrosinger/saasitone/pkg/context" - "git.grosinger.net/tgrosinger/saasitone/pkg/log" - "git.grosinger.net/tgrosinger/saasitone/pkg/services" ) -// ServeCachedPage attempts to load a page from the cache by matching on the complete request URL -// If a page is cached for the requested URL, it will be served here and the request terminated. -// Any request made by an authenticated user or that is not a GET will be skipped. -func ServeCachedPage(t *services.TemplateRenderer) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(ctx echo.Context) error { - // Skip non GET requests - if ctx.Request().Method != http.MethodGet { - return next(ctx) - } - - // Skip if the user is authenticated - if ctx.Get(context.AuthenticatedUserKey) != nil { - return next(ctx) - } - - // Attempt to load from cache - page, err := t.GetCachedPage(ctx, ctx.Request().URL.String()) - if err != nil { - switch { - case errors.Is(err, services.ErrCacheMiss): - case context.IsCanceledError(err): - return nil - default: - log.Ctx(ctx).Error("failed getting cached page", - "error", err, - ) - } - - return next(ctx) - } - - // Set any headers - if page.Headers != nil { - for k, v := range page.Headers { - ctx.Response().Header().Set(k, v) - } - } - - log.Ctx(ctx).Debug("serving cached page") - - return ctx.HTMLBlob(page.StatusCode, page.HTML) - } - } -} - // CacheControl sets a Cache-Control header with a given max age func CacheControl(maxAge time.Duration) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { diff --git a/pkg/middleware/cache_test.go b/pkg/middleware/cache_test.go index b71db96..3f4c65b 100644 --- a/pkg/middleware/cache_test.go +++ b/pkg/middleware/cache_test.go @@ -1,56 +1,14 @@ package middleware import ( - "net/http" "testing" "time" - "github.com/a-h/templ" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "git.grosinger.net/tgrosinger/saasitone/pkg/page" "git.grosinger.net/tgrosinger/saasitone/pkg/tests" - "git.grosinger.net/tgrosinger/saasitone/templ/pages" - "git.grosinger.net/tgrosinger/saasitone/templ/layouts" - "git.grosinger.net/tgrosinger/saasitone/templates" ) -func TestServeCachedPage(t *testing.T) { - // Cache a page - ctx, rec := tests.NewContext(c.Web, "/cache") - p := page.New(ctx) - p.Name = templates.PageHome - p.Cache.Enabled = true - p.Cache.Expiration = time.Minute - p.StatusCode = http.StatusCreated - p.Headers["a"] = "b" - p.Headers["c"] = "d" - p.LayoutComponent = func(content templ.Component) templ.Component { - return layouts.HTMX(p, content) - } - err := c.TemplateRenderer.RenderPageTempl(ctx, p, pages.Cache(p, &pages.CacheForm{}, nil)) - output := rec.Body.Bytes() - require.NoError(t, err) - - // Request the URL of the cached page - ctx, rec = tests.NewContext(c.Web, "/cache") - err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer)) - assert.NoError(t, err) - assert.Equal(t, p.StatusCode, ctx.Response().Status) - assert.Equal(t, p.Headers["a"], ctx.Response().Header().Get("a")) - assert.Equal(t, p.Headers["c"], ctx.Response().Header().Get("c")) - assert.Equal(t, output, rec.Body.Bytes()) - - // Login and try again - tests.InitSession(ctx) - err = c.Auth.Login(ctx, usr.ID) - require.NoError(t, err) - _ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth)) - err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer)) - assert.Nil(t, err) -} - func TestCacheControl(t *testing.T) { ctx, _ := tests.NewContext(c.Web, "/") _ = tests.ExecuteMiddleware(ctx, CacheControl(time.Second*5)) diff --git a/pkg/page/page.go b/pkg/page/page.go index 4f0c755..cd64276 100644 --- a/pkg/page/page.go +++ b/pkg/page/page.go @@ -52,11 +52,6 @@ type Page struct { // messages and markup presented to the user Form any - // Layout stores the name of the layout base template file which will be used when the page is rendered. - // This should match a template file located within the layouts directory inside the templates directory. - // The template extension should not be included in this value. - Layout templates.Layout - LayoutComponent func(content templ.Component) templ.Component // Name stores the name of the page as well as the name of the template file which will be used to render diff --git a/pkg/services/template_renderer.go b/pkg/services/template_renderer.go index b5e518a..4f3d22d 100644 --- a/pkg/services/template_renderer.go +++ b/pkg/services/template_renderer.go @@ -13,8 +13,6 @@ import ( "github.com/labstack/echo/v4" "git.grosinger.net/tgrosinger/saasitone/config" - "git.grosinger.net/tgrosinger/saasitone/pkg/context" - "git.grosinger.net/tgrosinger/saasitone/pkg/log" "git.grosinger.net/tgrosinger/saasitone/pkg/page" "git.grosinger.net/tgrosinger/saasitone/templates" ) @@ -137,141 +135,9 @@ func (t *TemplateRenderer) RenderPageTempl(ctx echo.Context, page page.Page, con page.HTMX.Response.Apply(ctx) } - // Cache this page, if caching was enabled - t.cachePage(ctx, page, &buf) - return ctx.HTMLBlob(ctx.Response().Status, buf.Bytes()) } -// RenderPage renders a Page as an HTTP response -func (t *TemplateRenderer) RenderPage(ctx echo.Context, page page.Page) error { - var buf *bytes.Buffer - var err error - templateGroup := "page" - - // Page name is required - if page.Name == "" { - return echo.NewHTTPError(http.StatusInternalServerError, "page render failed due to missing name") - } - - // Use the app name in configuration if a value was not set - if page.AppName == "" { - page.AppName = t.config.App.Name - } - - // 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 { - // Switch the layout which will only render the page content - page.Layout = templates.LayoutHTMX - - // Alter the template group so this is cached separately - templateGroup = "page:htmx" - } - - // Parse and execute the templates for the Page - // As mentioned in the documentation for the Page struct, the templates used for the page will be: - // 1. The layout/base template specified in Page.Layout - // 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 = t. - Parse(). - Group(templateGroup). - Key(string(page.Name)). - Base(string(page.Layout)). - Files( - fmt.Sprintf("layouts/%s", page.Layout), - fmt.Sprintf("pages/%s", page.Name), - ). - Directories("components"). - Execute(page) - if err != nil { - return echo.NewHTTPError( - http.StatusInternalServerError, - fmt.Sprintf("failed to parse and execute templates: %s", err), - ) - } - - // Set the status code - ctx.Response().Status = page.StatusCode - - // Set any headers - for k, v := range page.Headers { - ctx.Response().Header().Set(k, v) - } - - // Apply the HTMX response, if one - if page.HTMX.Response != nil { - page.HTMX.Response.Apply(ctx) - } - - // Cache this page, if caching was enabled - t.cachePage(ctx, page, buf) - - return ctx.HTMLBlob(ctx.Response().Status, buf.Bytes()) -} - -// cachePage caches the HTML for a given Page if the Page has caching enabled -func (t *TemplateRenderer) cachePage(ctx echo.Context, page page.Page, html *bytes.Buffer) { - if !page.Cache.Enabled || page.IsAuth { - return - } - - // If no expiration time was provided, default to the configuration value - if page.Cache.Expiration == 0 { - page.Cache.Expiration = t.config.Cache.Expiration.Page - } - - // Extract the headers - headers := make(map[string]string) - for k, v := range ctx.Response().Header() { - headers[k] = v[0] - } - - // The request URL is used as the cache key so the middleware can serve the - // cached page on matching requests - key := ctx.Request().URL.String() - cp := &CachedPage{ - URL: key, - HTML: html.Bytes(), - Headers: headers, - StatusCode: ctx.Response().Status, - } - - err := t.cache. - Set(). - Group(cachedPageGroup). - Key(key). - Tags(page.Cache.Tags...). - Expiration(page.Cache.Expiration). - Data(cp). - Save(ctx.Request().Context()) - - switch { - case err == nil: - log.Ctx(ctx).Debug("cached page") - case !context.IsCanceledError(err): - log.Ctx(ctx).Error("failed to cache page", - "error", err, - ) - } -} - -// GetCachedPage attempts to fetch a cached page for a given URL -func (t *TemplateRenderer) GetCachedPage(ctx echo.Context, url string) (*CachedPage, error) { - p, err := t.cache. - Get(). - Group(cachedPageGroup). - Key(url). - Fetch(ctx.Request().Context()) - if err != nil { - return nil, err - } - - return p.(*CachedPage), nil -} - // getCacheKey gets a cache key for a given group and ID func (t *TemplateRenderer) getCacheKey(group, key string) string { if group != "" { diff --git a/pkg/services/template_renderer_test.go b/pkg/services/template_renderer_test.go index 7411ca1..b2fa82f 100644 --- a/pkg/services/template_renderer_test.go +++ b/pkg/services/template_renderer_test.go @@ -1,21 +1,22 @@ package services import ( - "context" - "fmt" "net/http" "net/http/httptest" "testing" + "github.com/a-h/templ" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.grosinger.net/tgrosinger/saasitone/config" "git.grosinger.net/tgrosinger/saasitone/pkg/htmx" + "git.grosinger.net/tgrosinger/saasitone/pkg/models" "git.grosinger.net/tgrosinger/saasitone/pkg/page" "git.grosinger.net/tgrosinger/saasitone/pkg/tests" - "git.grosinger.net/tgrosinger/saasitone/templates" + "git.grosinger.net/tgrosinger/saasitone/templ/layouts" + "git.grosinger.net/tgrosinger/saasitone/templ/pages" ) func TestTemplateRenderer(t *testing.T) { @@ -31,9 +32,8 @@ func TestTemplateRenderer(t *testing.T) { Parse(). Group(group). Key(id). - Base("htmx"). - Files("layouts/htmx", "pages/error"). - Directories("components"). + Base("test"). + Files("emails/test"). Store() require.NoError(t, err) @@ -43,13 +43,7 @@ func TestTemplateRenderer(t *testing.T) { // Check that all expected templates are included expectedTemplates := make(map[string]bool) - expectedTemplates["htmx"+config.TemplateExt] = true - expectedTemplates["error"+config.TemplateExt] = true - components, err := templates.Get().ReadDir("components") - require.NoError(t, err) - for _, f := range components { - expectedTemplates[f.Name()] = true - } + expectedTemplates["test"+config.TemplateExt] = true for _, v := range parsed.Template.Templates() { delete(expectedTemplates, v.Name()) } @@ -63,20 +57,19 @@ func TestTemplateRenderer(t *testing.T) { buf, err := tpl.Execute(data) require.NoError(t, err) require.NotNil(t, buf) - assert.Contains(t, buf.String(), "Please try again") + assert.Contains(t, buf.String(), "Test email template") buf, err = c.TemplateRenderer. Parse(). Group(group). Key(id). - Base("htmx"). - Files("htmx", "pages/error"). - Directories("components"). + Base("test"). + Files("email/test"). Execute(data) require.NoError(t, err) require.NotNil(t, buf) - assert.Contains(t, buf.String(), "Please try again") + assert.Contains(t, buf.String(), "Test email template") } func TestTemplateRenderer_RenderPage(t *testing.T) { @@ -85,8 +78,7 @@ func TestTemplateRenderer_RenderPage(t *testing.T) { tests.InitSession(ctx) p := page.New(ctx) - p.Name = "home" - p.Layout = "main" + p.Name = "test" p.Cache.Enabled = false p.Headers["A"] = "b" p.Headers["C"] = "d" @@ -98,101 +90,27 @@ func TestTemplateRenderer_RenderPage(t *testing.T) { // Rendering should fail if the Page has no name ctx, _, p := setup() p.Name = "" - err := c.TemplateRenderer.RenderPage(ctx, p) + err := c.TemplateRenderer.RenderPageTempl(ctx, p, pages.Error(p)) assert.Error(t, err) }) - t.Run("no page cache", func(t *testing.T) { - ctx, _, p := setup() - err := c.TemplateRenderer.RenderPage(ctx, p) - require.NoError(t, err) - - // Check status code and headers - assert.Equal(t, http.StatusCreated, ctx.Response().Status) - for k, v := range p.Headers { - assert.Equal(t, v, ctx.Response().Header().Get(k)) - } - - // Check the template cache - parsed, err := c.TemplateRenderer.Load("page", string(p.Name)) - require.NoError(t, err) - - // Check that all expected templates were parsed. - // This includes the name, layout and all components - expectedTemplates := make(map[string]bool) - expectedTemplates[fmt.Sprintf("%s%s", p.Name, config.TemplateExt)] = true - expectedTemplates[fmt.Sprintf("%s%s", p.Layout, config.TemplateExt)] = true - components, err := templates.Get().ReadDir("components") - require.NoError(t, err) - for _, f := range components { - expectedTemplates[f.Name()] = true - } - - for _, v := range parsed.Template.Templates() { - delete(expectedTemplates, v.Name()) - } - assert.Empty(t, expectedTemplates) - }) - t.Run("htmx rendering", func(t *testing.T) { ctx, _, p := setup() p.HTMX.Request.Enabled = true p.HTMX.Response = &htmx.Response{ Trigger: "trigger", } - err := c.TemplateRenderer.RenderPage(ctx, p) + + component := pages.Home(p, []models.Post{}) + p.LayoutComponent = func(content templ.Component) templ.Component { + return layouts.Main(p, content) + } + + err := c.TemplateRenderer.RenderPageTempl(ctx, p, component) 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", string(p.Name)) - require.NoError(t, err) - - // Check that all expected templates were parsed. - // This includes the name, htmx and all components - expectedTemplates := make(map[string]bool) - expectedTemplates[fmt.Sprintf("%s%s", p.Name, config.TemplateExt)] = true - expectedTemplates["htmx"+config.TemplateExt] = true - components, err := templates.Get().ReadDir("components") - require.NoError(t, err) - for _, f := range components { - expectedTemplates[f.Name()] = true - } - - for _, v := range parsed.Template.Templates() { - delete(expectedTemplates, v.Name()) - } - assert.Empty(t, expectedTemplates) - }) - - t.Run("page cache", func(t *testing.T) { - ctx, rec, p := setup() - p.Cache.Enabled = true - p.Cache.Tags = []string{"tag1"} - err := c.TemplateRenderer.RenderPage(ctx, p) - require.NoError(t, err) - - // Fetch from the cache - cp, err := c.TemplateRenderer.GetCachedPage(ctx, p.URL) - require.NoError(t, err) - - // Compare the cached page - assert.Equal(t, p.URL, cp.URL) - assert.Equal(t, p.Headers, cp.Headers) - assert.Equal(t, p.StatusCode, cp.StatusCode) - assert.Equal(t, rec.Body.Bytes(), cp.HTML) - - // Clear the tag - err = c.Cache. - Flush(). - Tags(p.Cache.Tags[0]). - Execute(context.Background()) - require.NoError(t, err) - - // Refetch from the cache and expect no results - _, err = c.TemplateRenderer.GetCachedPage(ctx, p.URL) - assert.Error(t, err) }) } +