diff --git a/README.md b/README.md index 7859d95..dd3acfe 100644 --- a/README.md +++ b/README.md @@ -45,15 +45,13 @@ * [Email verification](#email-verification) * [Routes](#routes) * [Custom middleware](#custom-middleware) - * [Controller](#controller) * [Handlers](#handlers) * [Errors](#errors) * [Testing](#testing) * [HTTP server](#http-server) * [Request / Request helpers](#request--response-helpers) * [Goquery](#goquery) -* [Controller](#controller) - * [Page](#page) +* [Pages](#pages) * [Flash messaging](#flash-messaging) * [Pager](#pager) * [CSRF](#csrf) @@ -200,8 +198,7 @@ A new container can be created and initialized via `services.NewContainer()`. It ### Dependency injection -The container exists to faciliate easy dependency-injection both for services within the container as well as areas of your application that require any of these dependencies. For example, the container is passed to and stored within the `Controller` - so that the controller and the route using it have full, easy access to all services. +The container exists to faciliate easy dependency-injection both for services within the container as well as areas of your application that require any of these dependencies. For example, the container is automatically passed to the `Init()` method of your route handlers so that the handlers have full, easy access to all services. ### Test dependencies @@ -392,12 +389,6 @@ By default, a middleware stack is included in the router that makes sense for mo A `middleware` package is included which you can easily add to along with the custom middleware provided. -### Controller - -The `Controller`, which is described in a section below, provides base functionality which can be embedded in each handler, most importantly `Page` rendering. - -While using the `Controller` is not required for your routes, it will certainly make development easier. - ### Handlers A `Handler` is a simple type that handles one or more of your routes and allows you to group related routes together (ie, authentication). All provided handlers are located in `pkg/handlers`. _Handlers_ also handle self-registering their routes with the router. @@ -413,7 +404,7 @@ For this example, we'll create a new handler which includes a GET and POST route ```go type Example struct { orm *ent.Client - controller.Controller + *services.TemplateRenderer } ``` @@ -429,7 +420,7 @@ func init() { ```go func (e *Example) Init(c *services.Container) error { - e.Controller = controller.NewController(c) + e.TemplateRenderer = c.TemplateRenderer e.orm = c.ORM return nil } @@ -506,13 +497,9 @@ assert.Len(t, h1.Nodes, 1) assert.Equal(t, "About", h1.Text()) ``` -## Controller +## Pages -As previously mentioned, the `Controller` acts as a base for your routes, though it is optional. It stores the `Container` which houses all _Services_ (_dependencies_) but also a wide array of functionality aimed at allowing you to build complex responses with ease and consistency. - -### Page - -The `Page` is the major building block of your `Controller` responses. It is a _struct_ type located at `pkg/controller/page.go`. The concept of the `Page` is that it provides a consistent structure for building responses and transmitting data and functionality to the templates. +The `Page` is the major building block of your `Handler` responses. It is a _struct_ type located at `pkg/page/page.go`. The concept of the `Page` is that it provides a consistent structure for building responses and transmitting data and functionality to the templates. Pages are rendered with the `TemplateRenderer`. All example routes provided construct and _render_ a `Page`. It's recommended that you review both the `Page` and the example routes as they try to illustrate all included functionality. @@ -522,7 +509,7 @@ Initializing a new page is simple: ```go func (c *home) Get(ctx echo.Context) error { - page := controller.NewPage(ctx) + p := page.New(ctx) } ``` @@ -542,7 +529,7 @@ Using the `echo.Context`, the `Page` will be initialized with the following fiel ### Flash messaging -While flash messaging functionality is provided outside of the `Controller` and `Page`, within the `msg` package, it's really only used within this context. +Flash messaging functionality is provided within the `msg` package. It is used to provide one-time status messages to users. Flash messaging requires that [sessions](#sessions) and the session middleware are in place since that is where the messages are stored. @@ -566,7 +553,7 @@ To make things easier, a template _component_ is already provided, located at `t ### Pager -A very basic mechanism is provided to handle and facilitate paging located in `pkg/controller/pager.go`. When a `Page` is initialized, so is a `Pager` at `Page.Pager`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager. +A very basic mechanism is provided to handle and facilitate paging located in `pkg/page/pager.go`. When a `Page` is initialized, so is a `Pager` at `Page.Pager`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager. During initialization, the _items per page_ amount will be set to the default, controlled via constant, which has a value of 20. It can be overridden by changing `Pager.ItemsPerPage` but should be done before other values are set in order to not provide incorrect calculations. @@ -611,7 +598,7 @@ The [template renderer](#template-renderer) also provides caching and local hot- ### Cached responses -A `Page` can have cached enabled just by setting `Page.Cache.Enabled` to `true`. The `Controller` will automatically handle caching the HTML output, headers and status code. Cached pages are stored using a key that matches the full request URL and [middleware](#cache-middleware) is used to serve it on matching requests. +A `Page` can have cached enabled just by setting `Page.Cache.Enabled` to `true`. The `TemplateRenderer` will automatically handle caching the HTML output, headers and status code. Cached pages are stored using a key that matches the full request URL and [middleware](#cache-middleware) is used to serve it on matching requests. By default, the cache expiration time will be set according to the configuration value located at `Config.Cache.Expiration.Page` but it can be set per-page at `Page.Cache.Expiration`. @@ -654,8 +641,8 @@ Embedding `form.Submission` satisfies the `form.Form` interface and makes dealin Then in your page: ```go -page := controller.NewPage(ctx) -page.Form = form.Get[ContactForm](ctx) +p := page.New(ctx) +p.Form = form.Get[ContactForm](ctx) ``` This will either initialize a new form to be rendered, or load one previously stored in the context (ie, if it was already submitted). How the _form_ gets populated with values so that your template can render them is covered in the next section. @@ -722,8 +709,8 @@ Second, render the error messages, if there are any for a given field: HTTP headers can be set either via the `Page` or the _context_: ```go -page := controller.NewPage(ctx) -page.Headers["HeaderName"] = "header-value" +p := page.New(ctx) +p.Headers["HeaderName"] = "header-value" ``` ```go @@ -735,8 +722,8 @@ ctx.Response().Header().Set("HeaderName", "header-value") The HTTP response status code can be set either via the `Page` or the _context_: ```go -page := controller.NewPage(ctx) -page.StatusCode = http.StatusTooManyRequests +p := page.New(ctx) +p.StatusCode = http.StatusTooManyRequests ``` ```go @@ -748,21 +735,20 @@ ctx.Response().Status = http.StatusTooManyRequests The `Page` provides the ability to set basic HTML metatags which can be especially useful if your web application is publicly accessible. Only fields for the _description_ and _keywords_ are provided but adding additional fields is very easy. ```go -page := controller.NewPage(ctx) -page.Metatags.Description = "The page description." -page.Metatags.Keywords = []string{"Go", "Software"} +p := page.New(ctx) +p.Metatags.Description = "The page description." +p.Metatags.Keywords = []string{"Go", "Software"} ``` A _component_ template is included to render metatags in `core.gohtml` which can be used by adding `{{template "metatags" .}}` to your _layout_. ### URL and link generation -Generating URLs in the templates is made easy if you follow the [routing patterns](#patterns) and provide names for your routes. Echo provides a `Reverse` function to generate a route URL with a given route name and optional parameters. This function is made accessible to the templates via the `Page` field `ToURL`. +Generating URLs in the templates is made easy if you follow the [routing patterns](#patterns) and provide names for your routes. Echo provides a `Reverse` function to generate a route URL with a given route name and optional parameters. This function is made accessible to the templates via _funcmap_ function `url`. As an example, if you have route such as: ```go -profile := Profile{Controller: ctr} -e.GET("/user/profile/:user", profile.Get).Name = "user_profile" +e.GET("/user/profile/:user", handler.Get).Name = "user_profile" ``` And you want to generate a URL in the template, you can: @@ -832,14 +818,14 @@ If [CSRF](#csrf) protection is enabled, the token value will automatically be pa ### Rendering the page -Once your `Page` is fully built, rendering it via the embedded `Controller` in your _route_ can be done simply by calling `RenderPage()`: +Once your `Page` is fully built, rendering it via the embedded `TemplateRenderer` in your _handler_ can be done simply by calling `RenderPage()`: ```go func (c *home) Get(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutMain - page.Name = templates.PageHome - return c.RenderPage(ctx, page) + p := page.New(ctx) + p.Layout = templates.LayoutMain + p.Name = templates.PageHome + return c.RenderPage(ctx, p) } ``` @@ -868,10 +854,10 @@ This will do the following: - Include the [funcmap](#funcmap) - Execute the parsed template with `data` being passed in to the templates -Using the example from the [page rendering](#rendering-the-page), this is what the `Controller` will execute: +Using the example from the [page rendering](#rendering-the-page), this is will execute: ```go -buf, err = c.Container.TemplateRenderer. +buf, err = c.TemplateRenderer. Parse(). Group("page"). Key(page.Name). @@ -1100,7 +1086,7 @@ A service needs to run in order to add periodic tasks to the queue at the specif ```go go func() { if err := c.Tasks.StartScheduler(); err != nil { - c.Web.Logger.Fatalf("scheduler shutdown: %v", err) + log.Fatalf("scheduler shutdown: %v", err) } }() ``` diff --git a/cmd/web/main.go b/cmd/web/main.go index 23b3981..1d8045b 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "fmt" + "log" "net/http" "os" "os/signal" @@ -18,13 +19,13 @@ func main() { c := services.NewContainer() defer func() { if err := c.Shutdown(); err != nil { - c.Web.Logger.Fatal(err) + log.Fatal(err) } }() // Build the router if err := handlers.BuildRouter(c); err != nil { - c.Web.Logger.Fatalf("failed to build the router: %v", err) + log.Fatalf("failed to build the router: %v", err) } // Start the server @@ -40,7 +41,7 @@ func main() { if c.Config.HTTP.TLS.Enabled { certs, err := tls.LoadX509KeyPair(c.Config.HTTP.TLS.Certificate, c.Config.HTTP.TLS.Key) if err != nil { - c.Web.Logger.Fatalf("cannot load TLS certificate: %v", err) + log.Fatalf("cannot load TLS certificate: %v", err) } srv.TLSConfig = &tls.Config{ @@ -49,14 +50,14 @@ func main() { } if err := c.Web.StartServer(&srv); err != http.ErrServerClosed { - c.Web.Logger.Fatalf("shutting down the server: %v", err) + log.Fatalf("shutting down the server: %v", err) } }() // Start the scheduler service to queue periodic tasks go func() { if err := c.Tasks.StartScheduler(); err != nil { - c.Web.Logger.Fatalf("scheduler shutdown: %v", err) + log.Fatalf("scheduler shutdown: %v", err) } }() @@ -68,6 +69,6 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := c.Web.Shutdown(ctx); err != nil { - c.Web.Logger.Fatal(err) + log.Fatal(err) } } diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go deleted file mode 100644 index 4f216ff..0000000 --- a/pkg/controller/controller.go +++ /dev/null @@ -1,164 +0,0 @@ -package controller - -import ( - "bytes" - "fmt" - "net/http" - - "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/htmx" - "github.com/mikestefanello/pagoda/pkg/log" - "github.com/mikestefanello/pagoda/pkg/middleware" - "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/templates" - - "github.com/labstack/echo/v4" -) - -// Controller provides base functionality and dependencies to routes. -// The proposed pattern is to embed a Controller in each individual route struct and to use -// the router to inject the container so your routes have access to the services within the container -type Controller struct { - // Container stores a services container which contains dependencies - Container *services.Container -} - -// NewController creates a new Controller -func NewController(c *services.Container) Controller { - return Controller{ - Container: c, - } -} - -// RenderPage renders a Page as an HTTP response -func (c *Controller) RenderPage(ctx echo.Context, 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 = c.Container.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 = c.Container.TemplateRenderer. - 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 c.Fail(err, "failed to parse and execute templates") - } - - // 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 - c.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 (c *Controller) cachePage(ctx echo.Context, 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 = c.Container.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 := middleware.CachedPage{ - URL: key, - HTML: html.Bytes(), - Headers: headers, - StatusCode: ctx.Response().Status, - } - - err := c.Container.Cache. - Set(). - Group(middleware.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, - ) - } -} - -// Redirect redirects to a given route name with optional route parameters -func (c *Controller) Redirect(ctx echo.Context, route string, routeParams ...any) error { - url := ctx.Echo().Reverse(route, routeParams...) - - if htmx.GetRequest(ctx).Boosted { - htmx.Response{ - Redirect: url, - }.Apply(ctx) - - return nil - } else { - 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(err error, log string) error { - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("%s: %v", log, err)) -} diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go deleted file mode 100644 index b12b7d8..0000000 --- a/pkg/controller/controller_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package controller - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/mikestefanello/pagoda/config" - "github.com/mikestefanello/pagoda/pkg/htmx" - "github.com/mikestefanello/pagoda/pkg/middleware" - "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/pkg/tests" - "github.com/mikestefanello/pagoda/templates" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/labstack/echo/v4" -) - -var ( - c *services.Container -) - -func TestMain(m *testing.M) { - // Set the environment to test - config.SwitchEnvironment(config.EnvTest) - - // Create a new container - c = services.NewContainer() - - // Run tests - exitVal := m.Run() - - // Shutdown the container - if err := c.Shutdown(); err != nil { - panic(err) - } - - os.Exit(exitVal) -} - -func TestController_Redirect(t *testing.T) { - c.Web.GET("/path/:first/and/:second", func(c echo.Context) error { - return nil - }).Name = "redirect-test" - - ctx, _ := tests.NewContext(c.Web, "/abc") - ctr := NewController(c) - err := ctr.Redirect(ctx, "redirect-test", "one", "two") - require.NoError(t, err) - assert.Equal(t, "/path/one/and/two", ctx.Response().Header().Get(echo.HeaderLocation)) - assert.Equal(t, http.StatusFound, ctx.Response().Status) -} - -func TestController_RenderPage(t *testing.T) { - setup := func() (echo.Context, *httptest.ResponseRecorder, Controller, Page) { - ctx, rec := tests.NewContext(c.Web, "/test/TestController_RenderPage") - tests.InitSession(ctx) - ctr := NewController(c) - - p := NewPage(ctx) - p.Name = "home" - p.Layout = "main" - p.Cache.Enabled = false - p.Headers["A"] = "b" - p.Headers["C"] = "d" - p.StatusCode = http.StatusCreated - return ctx, rec, ctr, p - } - - t.Run("missing name", func(t *testing.T) { - // Rendering should fail if the Page has no name - ctx, _, ctr, p := setup() - p.Name = "" - err := ctr.RenderPage(ctx, p) - assert.Error(t, err) - }) - - t.Run("no page cache", func(t *testing.T) { - ctx, _, ctr, p := setup() - err := ctr.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, _, 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", 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, ctr, p := setup() - p.Cache.Enabled = true - p.Cache.Tags = []string{"tag1"} - err := ctr.RenderPage(ctx, p) - require.NoError(t, err) - - // Fetch from the cache - res, err := c.Cache. - Get(). - Group(middleware.CachedPageGroup). - Key(p.URL). - Type(new(middleware.CachedPage)). - Fetch(context.Background()) - require.NoError(t, err) - - // Compare the cached page - cp, ok := res.(*middleware.CachedPage) - require.True(t, ok) - 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.Cache. - Get(). - Group(middleware.CachedPageGroup). - Key(p.URL). - Type(new(middleware.CachedPage)). - Fetch(context.Background()) - assert.Error(t, err) - }) -} diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index 3fa1ee3..d608104 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -9,11 +9,11 @@ import ( "github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/ent/user" "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/controller" "github.com/mikestefanello/pagoda/pkg/form" "github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/middleware" "github.com/mikestefanello/pagoda/pkg/msg" + "github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/templates" ) @@ -36,7 +36,7 @@ type ( auth *services.AuthClient mail *services.MailClient orm *ent.Client - controller.Controller + *services.TemplateRenderer } forgotPasswordForm struct { @@ -69,51 +69,51 @@ func init() { Register(new(Auth)) } -func (c *Auth) Init(ct *services.Container) error { - c.Controller = controller.NewController(ct) - c.orm = ct.ORM - c.auth = ct.Auth - c.mail = ct.Mail +func (h *Auth) Init(c *services.Container) error { + h.TemplateRenderer = c.TemplateRenderer + h.orm = c.ORM + h.auth = c.Auth + h.mail = c.Mail return nil } -func (c *Auth) Routes(g *echo.Group) { - g.GET("/logout", c.Logout, middleware.RequireAuthentication()).Name = routeNameLogout - g.GET("/email/verify/:token", c.VerifyEmail).Name = routeNameVerifyEmail +func (h *Auth) Routes(g *echo.Group) { + g.GET("/logout", h.Logout, middleware.RequireAuthentication()).Name = routeNameLogout + g.GET("/email/verify/:token", h.VerifyEmail).Name = routeNameVerifyEmail noAuth := g.Group("/user", middleware.RequireNoAuthentication()) - noAuth.GET("/login", c.LoginPage).Name = routeNameLogin - noAuth.POST("/login", c.LoginSubmit).Name = routeNameLoginSubmit - noAuth.GET("/register", c.RegisterPage).Name = routeNameRegister - noAuth.POST("/register", c.RegisterSubmit).Name = routeNameRegisterSubmit - noAuth.GET("/password", c.ForgotPasswordPage).Name = routeNameForgotPassword - noAuth.POST("/password", c.ForgotPasswordSubmit).Name = routeNameForgotPasswordSubmit + noAuth.GET("/login", h.LoginPage).Name = routeNameLogin + noAuth.POST("/login", h.LoginSubmit).Name = routeNameLoginSubmit + noAuth.GET("/register", h.RegisterPage).Name = routeNameRegister + noAuth.POST("/register", h.RegisterSubmit).Name = routeNameRegisterSubmit + noAuth.GET("/password", h.ForgotPasswordPage).Name = routeNameForgotPassword + noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routeNameForgotPasswordSubmit resetGroup := noAuth.Group("/password/reset", - middleware.LoadUser(c.orm), - middleware.LoadValidPasswordToken(c.auth), + middleware.LoadUser(h.orm), + middleware.LoadValidPasswordToken(h.auth), ) - resetGroup.GET("/token/:user/:password_token/:token", c.ResetPasswordPage).Name = routeNameResetPassword - resetGroup.POST("/token/:user/:password_token/:token", c.ResetPasswordSubmit).Name = routeNameResetPasswordSubmit + resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routeNameResetPassword + resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routeNameResetPasswordSubmit } -func (c *Auth) ForgotPasswordPage(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutAuth - page.Name = templates.PageForgotPassword - page.Title = "Forgot password" - page.Form = form.Get[forgotPasswordForm](ctx) +func (h *Auth) ForgotPasswordPage(ctx echo.Context) error { + p := page.New(ctx) + p.Layout = templates.LayoutAuth + p.Name = templates.PageForgotPassword + p.Title = "Forgot password" + p.Form = form.Get[forgotPasswordForm](ctx) - return c.RenderPage(ctx, page) + return h.RenderPage(ctx, p) } -func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error { +func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error { var input forgotPasswordForm succeed := func() error { form.Clear(ctx) msg.Success(ctx, "An email containing a link to reset your password will be sent to this address if it exists in our system.") - return c.ForgotPasswordPage(ctx) + return h.ForgotPasswordPage(ctx) } err := form.Submit(ctx, &input) @@ -121,13 +121,13 @@ func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error { switch err.(type) { case nil: case validator.ValidationErrors: - return c.ForgotPasswordPage(ctx) + return h.ForgotPasswordPage(ctx) default: return err } // Attempt to load the user - u, err := c.orm.User. + u, err := h.orm.User. Query(). Where(user.Email(strings.ToLower(input.Email))). Only(ctx.Request().Context()) @@ -137,13 +137,13 @@ func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error { return succeed() case nil: default: - return c.Fail(err, "error querying user during forgot password") + return fail(err, "error querying user during forgot password") } // Generate the token - token, pt, err := c.auth.GeneratePasswordResetToken(ctx, u.ID) + token, pt, err := h.auth.GeneratePasswordResetToken(ctx, u.ID) if err != nil { - return c.Fail(err, "error generating password reset token") + return fail(err, "error generating password reset token") } log.Ctx(ctx).Info("generated password reset token", @@ -152,7 +152,7 @@ func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error { // Email the user url := ctx.Echo().Reverse(routeNameResetPassword, u.ID, pt.ID, token) - err = c.mail. + err = h.mail. Compose(). To(u.Email). Subject("Reset your password"). @@ -160,30 +160,30 @@ func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error { Send(ctx) if err != nil { - return c.Fail(err, "error sending password reset email") + return fail(err, "error sending password reset email") } return succeed() } -func (c *Auth) LoginPage(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutAuth - page.Name = templates.PageLogin - page.Title = "Log in" - page.Form = form.Get[loginForm](ctx) +func (h *Auth) LoginPage(ctx echo.Context) error { + p := page.New(ctx) + p.Layout = templates.LayoutAuth + p.Name = templates.PageLogin + p.Title = "Log in" + p.Form = form.Get[loginForm](ctx) - return c.RenderPage(ctx, page) + return h.RenderPage(ctx, p) } -func (c *Auth) LoginSubmit(ctx echo.Context) error { +func (h *Auth) LoginSubmit(ctx echo.Context) error { var input loginForm authFailed := func() error { input.SetFieldError("Email", "") input.SetFieldError("Password", "") msg.Danger(ctx, "Invalid credentials. Please try again.") - return c.LoginPage(ctx) + return h.LoginPage(ctx) } err := form.Submit(ctx, &input) @@ -191,13 +191,13 @@ func (c *Auth) LoginSubmit(ctx echo.Context) error { switch err.(type) { case nil: case validator.ValidationErrors: - return c.LoginPage(ctx) + return h.LoginPage(ctx) default: return err } // Attempt to load the user - u, err := c.orm.User. + u, err := h.orm.User. Query(). Where(user.Email(strings.ToLower(input.Email))). Only(ctx.Request().Context()) @@ -207,45 +207,45 @@ func (c *Auth) LoginSubmit(ctx echo.Context) error { return authFailed() case nil: default: - return c.Fail(err, "error querying user during login") + return fail(err, "error querying user during login") } // Check if the password is correct - err = c.auth.CheckPassword(input.Password, u.Password) + err = h.auth.CheckPassword(input.Password, u.Password) if err != nil { return authFailed() } // Log the user in - err = c.auth.Login(ctx, u.ID) + err = h.auth.Login(ctx, u.ID) if err != nil { - return c.Fail(err, "unable to log in user") + return fail(err, "unable to log in user") } msg.Success(ctx, fmt.Sprintf("Welcome back, %s. You are now logged in.", u.Name)) - return c.Redirect(ctx, routeNameHome) + return redirect(ctx, routeNameHome) } -func (c *Auth) Logout(ctx echo.Context) error { - if err := c.auth.Logout(ctx); err == nil { +func (h *Auth) Logout(ctx echo.Context) error { + if err := h.auth.Logout(ctx); err == nil { msg.Success(ctx, "You have been logged out successfully.") } else { msg.Danger(ctx, "An error occurred. Please try again.") } - return c.Redirect(ctx, routeNameHome) + return redirect(ctx, routeNameHome) } -func (c *Auth) RegisterPage(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutAuth - page.Name = templates.PageRegister - page.Title = "Register" - page.Form = form.Get[registerForm](ctx) +func (h *Auth) RegisterPage(ctx echo.Context) error { + p := page.New(ctx) + p.Layout = templates.LayoutAuth + p.Name = templates.PageRegister + p.Title = "Register" + p.Form = form.Get[registerForm](ctx) - return c.RenderPage(ctx, page) + return h.RenderPage(ctx, p) } -func (c *Auth) RegisterSubmit(ctx echo.Context) error { +func (h *Auth) RegisterSubmit(ctx echo.Context) error { var input registerForm err := form.Submit(ctx, &input) @@ -253,19 +253,19 @@ func (c *Auth) RegisterSubmit(ctx echo.Context) error { switch err.(type) { case nil: case validator.ValidationErrors: - return c.RegisterPage(ctx) + return h.RegisterPage(ctx) default: return err } // Hash the password - pwHash, err := c.auth.HashPassword(input.Password) + pwHash, err := h.auth.HashPassword(input.Password) if err != nil { - return c.Fail(err, "unable to hash password") + return fail(err, "unable to hash password") } // Attempt creating the user - u, err := c.orm.User. + u, err := h.orm.User. Create(). SetName(input.Name). SetEmail(input.Email). @@ -280,33 +280,33 @@ func (c *Auth) RegisterSubmit(ctx echo.Context) error { ) case *ent.ConstraintError: msg.Warning(ctx, "A user with this email address already exists. Please log in.") - return c.Redirect(ctx, routeNameLogin) + return redirect(ctx, routeNameLogin) default: - return c.Fail(err, "unable to create user") + return fail(err, "unable to create user") } // Log the user in - err = c.auth.Login(ctx, u.ID) + err = h.auth.Login(ctx, u.ID) if err != nil { log.Ctx(ctx).Error("unable to log user in", "error", err, "user_id", u.ID, ) msg.Info(ctx, "Your account has been created.") - return c.Redirect(ctx, routeNameLogin) + return redirect(ctx, routeNameLogin) } msg.Success(ctx, "Your account has been created. You are now logged in.") // Send the verification email - c.sendVerificationEmail(ctx, u) + h.sendVerificationEmail(ctx, u) - return c.Redirect(ctx, routeNameHome) + return redirect(ctx, routeNameHome) } -func (c *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { +func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { // Generate a token - token, err := c.auth.GenerateEmailVerificationToken(usr.Email) + token, err := h.auth.GenerateEmailVerificationToken(usr.Email) if err != nil { log.Ctx(ctx).Error("unable to generate email verification token", "user_id", usr.ID, @@ -317,7 +317,7 @@ func (c *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { // Send the email url := ctx.Echo().Reverse(routeNameVerifyEmail, token) - err = c.mail. + err = h.mail. Compose(). To(usr.Email). Subject("Confirm your email address"). @@ -335,17 +335,17 @@ func (c *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { msg.Info(ctx, "An email was sent to you to verify your email address.") } -func (c *Auth) ResetPasswordPage(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutAuth - page.Name = templates.PageResetPassword - page.Title = "Reset password" - page.Form = form.Get[resetPasswordForm](ctx) +func (h *Auth) ResetPasswordPage(ctx echo.Context) error { + p := page.New(ctx) + p.Layout = templates.LayoutAuth + p.Name = templates.PageResetPassword + p.Title = "Reset password" + p.Form = form.Get[resetPasswordForm](ctx) - return c.RenderPage(ctx, page) + return h.RenderPage(ctx, p) } -func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error { +func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error { var input resetPasswordForm err := form.Submit(ctx, &input) @@ -353,15 +353,15 @@ func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error { switch err.(type) { case nil: case validator.ValidationErrors: - return c.ResetPasswordPage(ctx) + return h.ResetPasswordPage(ctx) default: return err } // Hash the new password - hash, err := c.auth.HashPassword(input.Password) + hash, err := h.auth.HashPassword(input.Password) if err != nil { - return c.Fail(err, "unable to hash password") + return fail(err, "unable to hash password") } // Get the requesting user @@ -374,28 +374,28 @@ func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error { Save(ctx.Request().Context()) if err != nil { - return c.Fail(err, "unable to update password") + return fail(err, "unable to update password") } // Delete all password tokens for this user - err = c.auth.DeletePasswordTokens(ctx, usr.ID) + err = h.auth.DeletePasswordTokens(ctx, usr.ID) if err != nil { - return c.Fail(err, "unable to delete password tokens") + return fail(err, "unable to delete password tokens") } msg.Success(ctx, "Your password has been updated.") - return c.Redirect(ctx, routeNameLogin) + return redirect(ctx, routeNameLogin) } -func (c *Auth) VerifyEmail(ctx echo.Context) error { +func (h *Auth) VerifyEmail(ctx echo.Context) error { var usr *ent.User // Validate the token token := ctx.Param("token") - email, err := c.auth.ValidateEmailVerificationToken(token) + email, err := h.auth.ValidateEmailVerificationToken(token) if err != nil { msg.Warning(ctx, "The link is either invalid or has expired.") - return c.Redirect(ctx, routeNameHome) + return redirect(ctx, routeNameHome) } // Check if it matches the authenticated user @@ -409,13 +409,13 @@ func (c *Auth) VerifyEmail(ctx echo.Context) error { // Query to find a matching user, if needed if usr == nil { - usr, err = c.orm.User. + usr, err = h.orm.User. Query(). Where(user.Email(email)). Only(ctx.Request().Context()) if err != nil { - return c.Fail(err, "query failed loading email verification token user") + return fail(err, "query failed loading email verification token user") } } @@ -427,10 +427,10 @@ func (c *Auth) VerifyEmail(ctx echo.Context) error { Save(ctx.Request().Context()) if err != nil { - return c.Fail(err, "failed to set user as verified") + return fail(err, "failed to set user as verified") } } msg.Success(ctx, "Your email has been successfully verified.") - return c.Redirect(ctx, routeNameHome) + return redirect(ctx, routeNameHome) } diff --git a/pkg/handlers/contact.go b/pkg/handlers/contact.go index 7de83df..16a5bfb 100644 --- a/pkg/handlers/contact.go +++ b/pkg/handlers/contact.go @@ -5,8 +5,8 @@ import ( "github.com/go-playground/validator/v10" "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/pkg/controller" "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/templates" ) @@ -19,7 +19,7 @@ const ( type ( Contact struct { mail *services.MailClient - controller.Controller + *services.TemplateRenderer } contactForm struct { @@ -34,28 +34,28 @@ func init() { Register(new(Contact)) } -func (c *Contact) Init(ct *services.Container) error { - c.Controller = controller.NewController(ct) - c.mail = ct.Mail +func (h *Contact) Init(c *services.Container) error { + h.TemplateRenderer = c.TemplateRenderer + h.mail = c.Mail return nil } -func (c *Contact) Routes(g *echo.Group) { - g.GET("/contact", c.Page).Name = routeNameContact - g.POST("/contact", c.Submit).Name = routeNameContactSubmit +func (h *Contact) Routes(g *echo.Group) { + g.GET("/contact", h.Page).Name = routeNameContact + g.POST("/contact", h.Submit).Name = routeNameContactSubmit } -func (c *Contact) Page(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutMain - page.Name = templates.PageContact - page.Title = "Contact us" - page.Form = form.Get[contactForm](ctx) +func (h *Contact) Page(ctx echo.Context) error { + p := page.New(ctx) + p.Layout = templates.LayoutMain + p.Name = templates.PageContact + p.Title = "Contact us" + p.Form = form.Get[contactForm](ctx) - return c.RenderPage(ctx, page) + return h.RenderPage(ctx, p) } -func (c *Contact) Submit(ctx echo.Context) error { +func (h *Contact) Submit(ctx echo.Context) error { var input contactForm err := form.Submit(ctx, &input) @@ -63,12 +63,12 @@ func (c *Contact) Submit(ctx echo.Context) error { switch err.(type) { case nil: case validator.ValidationErrors: - return c.Page(ctx) + return h.Page(ctx) default: return err } - err = c.mail. + err = h.mail. Compose(). To(input.Email). Subject("Contact form submitted"). @@ -76,8 +76,8 @@ func (c *Contact) Submit(ctx echo.Context) error { Send(ctx) if err != nil { - return c.Fail(err, "unable to send email") + return fail(err, "unable to send email") } - return c.Page(ctx) + return h.Page(ctx) } diff --git a/pkg/handlers/error.go b/pkg/handlers/error.go index 7b451c0..fd5502b 100644 --- a/pkg/handlers/error.go +++ b/pkg/handlers/error.go @@ -5,13 +5,14 @@ import ( "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/controller" "github.com/mikestefanello/pagoda/pkg/log" + "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/templates" ) type Error struct { - controller.Controller + *services.TemplateRenderer } func (e *Error) Page(err error, ctx echo.Context) { @@ -26,19 +27,23 @@ func (e *Error) Page(err error, ctx echo.Context) { } // Log the error - if code >= 500 { - log.Ctx(ctx).Error(err.Error()) + logger := log.Ctx(ctx) + switch { + case code >= 500: + logger.Error(err.Error()) + case code >= 400: + logger.Warn(err.Error()) } // Render the error page - page := controller.NewPage(ctx) - page.Layout = templates.LayoutMain - page.Name = templates.PageError - page.Title = http.StatusText(code) - page.StatusCode = code - page.HTMX.Request.Enabled = false + p := page.New(ctx) + p.Layout = templates.LayoutMain + p.Name = templates.PageError + p.Title = http.StatusText(code) + p.StatusCode = code + p.HTMX.Request.Enabled = false - if err = e.RenderPage(ctx, page); err != nil { + if err = e.RenderPage(ctx, p); err != nil { log.Ctx(ctx).Error("failed to render error page", "error", err, ) diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go index 8fc9ee3..b225669 100644 --- a/pkg/handlers/handlers.go +++ b/pkg/handlers/handlers.go @@ -1,7 +1,11 @@ package handlers import ( + "fmt" + "net/http" + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/htmx" "github.com/mikestefanello/pagoda/pkg/services" ) @@ -25,3 +29,24 @@ func Register(h Handler) { func GetHandlers() []Handler { return handlers } + +// redirect redirects to a given route name with optional route parameters +func redirect(ctx echo.Context, route string, routeParams ...any) error { + url := ctx.Echo().Reverse(route, routeParams...) + + if htmx.GetRequest(ctx).Boosted { + htmx.Response{ + Redirect: url, + }.Apply(ctx) + + return nil + } else { + return ctx.Redirect(http.StatusFound, url) + } +} + +// fail is a helper to fail a request by returning a 500 error and logging the error +func fail(err error, log string) error { + // The error handler will handle logging + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("%s: %v", log, err)) +} diff --git a/pkg/handlers/handlers_test.go b/pkg/handlers/handlers_test.go new file mode 100644 index 0000000..3ef6408 --- /dev/null +++ b/pkg/handlers/handlers_test.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "errors" + "net/http" + "testing" + + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/htmx" + "github.com/mikestefanello/pagoda/pkg/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetSetHandlers(t *testing.T) { + handlers = []Handler{} + assert.Empty(t, GetHandlers()) + h := new(Pages) + Register(h) + got := GetHandlers() + require.Len(t, got, 1) + assert.Equal(t, h, got[0]) +} + +func TestRedirect(t *testing.T) { + c.Web.GET("/path/:first/and/:second", func(c echo.Context) error { + return nil + }).Name = "redirect-test" + + t.Run("normal", func(t *testing.T) { + ctx, _ := tests.NewContext(c.Web, "/abc") + err := redirect(ctx, "redirect-test", "one", "two") + require.NoError(t, err) + assert.Equal(t, "/path/one/and/two", ctx.Response().Header().Get(echo.HeaderLocation)) + assert.Equal(t, http.StatusFound, ctx.Response().Status) + }) + + t.Run("htmx boosted", func(t *testing.T) { + ctx, _ := tests.NewContext(c.Web, "/abc") + ctx.Request().Header.Set(htmx.HeaderBoosted, "true") + err := redirect(ctx, "redirect-test", "one", "two") + require.NoError(t, err) + assert.Equal(t, "/path/one/and/two", ctx.Response().Header().Get(htmx.HeaderRedirect)) + }) +} + +func TestFail(t *testing.T) { + err := fail(errors.New("err message"), "log message") + require.IsType(t, new(echo.HTTPError), err) + he := err.(*echo.HTTPError) + assert.Equal(t, http.StatusInternalServerError, he.Code) + assert.Equal(t, "log message: err message", he.Message) +} diff --git a/pkg/handlers/pages.go b/pkg/handlers/pages.go index 3cb5bc4..481ec4a 100644 --- a/pkg/handlers/pages.go +++ b/pkg/handlers/pages.go @@ -5,7 +5,7 @@ import ( "html/template" "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/pkg/controller" + "github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/templates" ) @@ -17,7 +17,7 @@ const ( type ( Pages struct { - controller.Controller + *services.TemplateRenderer } post struct { @@ -41,30 +41,30 @@ func init() { Register(new(Pages)) } -func (c *Pages) Init(ct *services.Container) error { - c.Controller = controller.NewController(ct) +func (h *Pages) Init(c *services.Container) error { + h.TemplateRenderer = c.TemplateRenderer return nil } -func (c *Pages) Routes(g *echo.Group) { - g.GET("/", c.Home).Name = routeNameHome - g.GET("/about", c.About).Name = routeNameAbout +func (h *Pages) Routes(g *echo.Group) { + g.GET("/", h.Home).Name = routeNameHome + g.GET("/about", h.About).Name = routeNameAbout } -func (c *Pages) Home(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutMain - page.Name = templates.PageHome - page.Metatags.Description = "Welcome to the homepage." - page.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"} - page.Pager = controller.NewPager(ctx, 4) - page.Data = c.fetchPosts(&page.Pager) +func (h *Pages) Home(ctx echo.Context) error { + p := page.New(ctx) + p.Layout = templates.LayoutMain + p.Name = templates.PageHome + p.Metatags.Description = "Welcome to the homepage." + p.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"} + p.Pager = page.NewPager(ctx, 4) + p.Data = h.fetchPosts(&p.Pager) - return c.RenderPage(ctx, page) + return h.RenderPage(ctx, p) } // fetchPosts is an mock example of fetching posts to illustrate how paging works -func (c *Pages) fetchPosts(pager *controller.Pager) []post { +func (h *Pages) fetchPosts(pager *page.Pager) []post { pager.SetItems(20) posts := make([]post, 20) @@ -77,19 +77,19 @@ func (c *Pages) fetchPosts(pager *controller.Pager) []post { return posts[pager.GetOffset() : pager.GetOffset()+pager.ItemsPerPage] } -func (c *Pages) About(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutMain - page.Name = templates.PageAbout - page.Title = "About" +func (h *Pages) About(ctx echo.Context) error { + p := page.New(ctx) + p.Layout = templates.LayoutMain + p.Name = templates.PageAbout + p.Title = "About" // This page will be cached! - page.Cache.Enabled = true - page.Cache.Tags = []string{"page_about", "page:list"} + p.Cache.Enabled = true + p.Cache.Tags = []string{"page_about", "page:list"} // A simple example of how the Data field can contain anything you want to send to the templates // even though you wouldn't normally send markup like this - page.Data = aboutData{ + p.Data = aboutData{ ShowCacheWarning: true, FrontendTabs: []aboutTab{ { @@ -117,5 +117,5 @@ func (c *Pages) About(ctx echo.Context) error { }, } - return c.RenderPage(ctx, page) + return h.RenderPage(ctx, p) } diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go index c77713d..b7b6c2a 100644 --- a/pkg/handlers/router.go +++ b/pkg/handlers/router.go @@ -7,7 +7,6 @@ import ( "github.com/labstack/echo-contrib/session" echomw "github.com/labstack/echo/v4/middleware" "github.com/mikestefanello/pagoda/config" - "github.com/mikestefanello/pagoda/pkg/controller" "github.com/mikestefanello/pagoda/pkg/middleware" "github.com/mikestefanello/pagoda/pkg/services" ) @@ -43,14 +42,14 @@ func BuildRouter(c *services.Container) error { }), session.Middleware(sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))), middleware.LoadAuthenticatedUser(c.Auth), - middleware.ServeCachedPage(c.Cache), + middleware.ServeCachedPage(c.TemplateRenderer), echomw.CSRFWithConfig(echomw.CSRFConfig{ TokenLookup: "form:csrf", }), ) // Error handler - err := Error{Controller: controller.NewController(c)} + err := Error{c.TemplateRenderer} c.Web.HTTPErrorHandler = err.Page // Initialize and register all handlers diff --git a/pkg/handlers/search.go b/pkg/handlers/search.go index fd0d739..06ebd30 100644 --- a/pkg/handlers/search.go +++ b/pkg/handlers/search.go @@ -5,7 +5,7 @@ import ( "math/rand" "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/pkg/controller" + "github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/templates" ) @@ -14,7 +14,7 @@ const routeNameSearch = "search" type ( Search struct { - controller.Controller + *services.TemplateRenderer } searchResult struct { @@ -27,19 +27,19 @@ func init() { Register(new(Search)) } -func (c *Search) Init(ct *services.Container) error { - c.Controller = controller.NewController(ct) +func (h *Search) Init(c *services.Container) error { + h.TemplateRenderer = c.TemplateRenderer return nil } -func (c *Search) Routes(g *echo.Group) { - g.GET("/search", c.Page).Name = routeNameSearch +func (h *Search) Routes(g *echo.Group) { + g.GET("/search", h.Page).Name = routeNameSearch } -func (c *Search) Page(ctx echo.Context) error { - page := controller.NewPage(ctx) - page.Layout = templates.LayoutMain - page.Name = templates.PageSearch +func (h *Search) Page(ctx echo.Context) error { + p := page.New(ctx) + p.Layout = templates.LayoutMain + p.Name = templates.PageSearch // Fake search results var results []searchResult @@ -54,7 +54,7 @@ func (c *Search) Page(ctx echo.Context) error { }) } } - page.Data = results + p.Data = results - return c.RenderPage(ctx, page) + return h.RenderPage(ctx, p) } diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 833597c..6194b60 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -7,6 +7,7 @@ import ( "github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/pkg/context" + "github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/services" @@ -20,11 +21,10 @@ func LoadAuthenticatedUser(authClient *services.AuthClient) echo.MiddlewareFunc u, err := authClient.GetAuthenticatedUser(c) switch err.(type) { case *ent.NotFoundError: - c.Logger().Warn("auth user not found") + log.Ctx(c).Warn("auth user not found") case services.NotAuthenticatedError: case nil: c.Set(context.AuthenticatedUserKey, u) - c.Logger().Infof("auth user loaded in to context: %d", u.ID) default: return echo.NewHTTPError( http.StatusInternalServerError, diff --git a/pkg/middleware/cache.go b/pkg/middleware/cache.go index 40286c8..fcfa43b 100644 --- a/pkg/middleware/cache.go +++ b/pkg/middleware/cache.go @@ -7,83 +7,56 @@ import ( "time" "github.com/mikestefanello/pagoda/pkg/context" + "github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/services" libstore "github.com/eko/gocache/lib/v4/store" "github.com/labstack/echo/v4" ) -// CachedPageGroup stores the cache group for cached pages -const CachedPageGroup = "page" - -// CachedPage is what is used to store a rendered Page in the cache -type CachedPage struct { - // URL stores the URL of the requested page - URL string - - // HTML stores the complete HTML of the rendered Page - HTML []byte - - // StatusCode stores the HTTP status code - StatusCode int - - // Headers stores the HTTP headers - Headers map[string]string -} - // 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(ch *services.CacheClient) echo.MiddlewareFunc { +func ServeCachedPage(t *services.TemplateRenderer) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { + return func(ctx echo.Context) error { // Skip non GET requests - if c.Request().Method != http.MethodGet { - return next(c) + if ctx.Request().Method != http.MethodGet { + return next(ctx) } // Skip if the user is authenticated - if c.Get(context.AuthenticatedUserKey) != nil { - return next(c) + if ctx.Get(context.AuthenticatedUserKey) != nil { + return next(ctx) } // Attempt to load from cache - res, err := ch. - Get(). - Group(CachedPageGroup). - Key(c.Request().URL.String()). - Type(new(CachedPage)). - Fetch(c.Request().Context()) + page, err := t.GetCachedPage(ctx, ctx.Request().URL.String()) if err != nil { switch { case errors.Is(err, &libstore.NotFound{}): - c.Logger().Info("no cached page found") case context.IsCanceledError(err): return nil default: - c.Logger().Errorf("failed getting cached page: %v", err) + log.Ctx(ctx).Error("failed getting cached page", + "error", err, + ) } - return next(c) - } - - page, ok := res.(*CachedPage) - if !ok { - c.Logger().Errorf("failed casting cached page") - return next(c) + return next(ctx) } // Set any headers if page.Headers != nil { for k, v := range page.Headers { - c.Response().Header().Set(k, v) + ctx.Response().Header().Set(k, v) } } - c.Logger().Info("serving cached page") + log.Ctx(ctx).Debug("serving cached page") - return c.HTMLBlob(page.StatusCode, page.HTML) + return ctx.HTMLBlob(page.StatusCode, page.HTML) } } } @@ -91,13 +64,13 @@ func ServeCachedPage(ch *services.CacheClient) echo.MiddlewareFunc { // 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 { - return func(c echo.Context) error { + return func(ctx echo.Context) error { v := "no-cache, no-store" if maxAge > 0 { v = fmt.Sprintf("public, max-age=%.0f", maxAge.Seconds()) } - c.Response().Header().Set("Cache-Control", v) - return next(c) + ctx.Response().Header().Set("Cache-Control", v) + return next(ctx) } } } diff --git a/pkg/middleware/cache_test.go b/pkg/middleware/cache_test.go index 948c924..fcd7fb4 100644 --- a/pkg/middleware/cache_test.go +++ b/pkg/middleware/cache_test.go @@ -1,12 +1,13 @@ package middleware import ( - "context" "net/http" "testing" "time" + "github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/tests" + "github.com/mikestefanello/pagoda/templates" "github.com/stretchr/testify/require" @@ -15,38 +16,34 @@ import ( func TestServeCachedPage(t *testing.T) { // Cache a page - cp := CachedPage{ - URL: "/cache", - HTML: []byte("html"), - Headers: make(map[string]string), - StatusCode: http.StatusCreated, - } - cp.Headers["a"] = "b" - cp.Headers["c"] = "d" - - err := c.Cache. - Set(). - Group(CachedPageGroup). - Key(cp.URL). - Data(cp). - Save(context.Background()) + ctx, rec := tests.NewContext(c.Web, "/cache") + p := page.New(ctx) + p.Layout = templates.LayoutHTMX + 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" + err := c.TemplateRenderer.RenderPage(ctx, p) + output := rec.Body.Bytes() require.NoError(t, err) // Request the URL of the cached page - ctx, rec := tests.NewContext(c.Web, cp.URL) - err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.Cache)) + ctx, rec = tests.NewContext(c.Web, "/cache") + err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer)) assert.NoError(t, err) - assert.Equal(t, cp.StatusCode, ctx.Response().Status) - assert.Equal(t, cp.Headers["a"], ctx.Response().Header().Get("a")) - assert.Equal(t, cp.Headers["c"], ctx.Response().Header().Get("c")) - assert.Equal(t, cp.HTML, rec.Body.Bytes()) + 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.Cache)) + err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer)) assert.Nil(t, err) } diff --git a/pkg/controller/page.go b/pkg/page/page.go similarity index 90% rename from pkg/controller/page.go rename to pkg/page/page.go index 9bed5f2..e5bbd8e 100644 --- a/pkg/controller/page.go +++ b/pkg/page/page.go @@ -1,4 +1,4 @@ -package controller +package page import ( "html/template" @@ -7,7 +7,6 @@ import ( "github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/form" "github.com/mikestefanello/pagoda/pkg/htmx" "github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/templates" @@ -17,9 +16,9 @@ import ( "github.com/labstack/echo/v4" ) -// Page consists of all data that will be used to render a page response for a given controller. -// While it's not required for a controller to render a Page on a route, this is the common data -// object that will be passed to the templates, making it easy for all controllers to share +// Page consists of all data that will be used to render a page response for a given route. +// While it's not required for a handler to render a Page on a route, this is the common data +// object that will be passed to the templates, making it easy for all handlers to share // functionality both on the back and frontend. The Page can be expanded to include anything else // your app wants to support. // Methods on this page also then become available in the templates, which can be more useful than @@ -42,14 +41,14 @@ type Page struct { URL string // Data stores whatever additional data that needs to be passed to the templates. - // This is what the controller uses to pass the content of the page. + // This is what the handler uses to pass the content of the page. Data any // Form stores a struct that represents a form on the page. // This should be a struct with fields for each form field, using both "form" and "validate" tags - // It should also contain a Submission field of type FormSubmission if you wish to have validation + // It should also contain form.FormSubmission if you wish to have validation // messages and markup presented to the user - Form form.Form + 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. @@ -123,8 +122,8 @@ type Page struct { } } -// NewPage creates and initiatizes a new Page for a given request context -func NewPage(ctx echo.Context) Page { +// New creates and initiatizes a new Page for a given request context +func New(ctx echo.Context) Page { p := Page{ Context: ctx, Path: ctx.Request().URL.Path, diff --git a/pkg/controller/page_test.go b/pkg/page/page_test.go similarity index 81% rename from pkg/controller/page_test.go rename to pkg/page/page_test.go index e7c0f2f..1ab10cb 100644 --- a/pkg/controller/page_test.go +++ b/pkg/page/page_test.go @@ -1,21 +1,23 @@ -package controller +package page import ( "net/http" "testing" + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/tests" echomw "github.com/labstack/echo/v4/middleware" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestNewPage(t *testing.T) { - ctx, _ := tests.NewContext(c.Web, "/") - p := NewPage(ctx) +func TestNew(t *testing.T) { + e := echo.New() + ctx, _ := tests.NewContext(e, "/") + p := New(ctx) assert.Same(t, ctx, p.Context) assert.Equal(t, "/", p.Path) assert.Equal(t, "/", p.URL) @@ -28,12 +30,13 @@ func TestNewPage(t *testing.T) { assert.Empty(t, p.RequestID) assert.False(t, p.Cache.Enabled) - ctx, _ = tests.NewContext(c.Web, "/abc?def=123") - usr, err := tests.CreateUser(c.ORM) - require.NoError(t, err) + ctx, _ = tests.NewContext(e, "/abc?def=123") + usr := &ent.User{ + ID: 1, + } ctx.Set(context.AuthenticatedUserKey, usr) ctx.Set(echomw.DefaultCSRFConfig.ContextKey, "csrf") - p = NewPage(ctx) + p = New(ctx) assert.Equal(t, "/abc", p.Path) assert.Equal(t, "/abc?def=123", p.URL) assert.False(t, p.IsHome) @@ -43,9 +46,9 @@ func TestNewPage(t *testing.T) { } func TestPage_GetMessages(t *testing.T) { - ctx, _ := tests.NewContext(c.Web, "/") + ctx, _ := tests.NewContext(echo.New(), "/") tests.InitSession(ctx) - p := NewPage(ctx) + p := New(ctx) // Set messages msgTests := make(map[msg.Type][]string) diff --git a/pkg/controller/pager.go b/pkg/page/pager.go similarity index 98% rename from pkg/controller/pager.go rename to pkg/page/pager.go index 39710d2..050da74 100644 --- a/pkg/controller/pager.go +++ b/pkg/page/pager.go @@ -1,4 +1,4 @@ -package controller +package page import ( "math" diff --git a/pkg/controller/pager_test.go b/pkg/page/pager_test.go similarity index 73% rename from pkg/controller/pager_test.go rename to pkg/page/pager_test.go index 694fbc3..b8aaaea 100644 --- a/pkg/controller/pager_test.go +++ b/pkg/page/pager_test.go @@ -1,33 +1,35 @@ -package controller +package page import ( "fmt" "testing" + "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/tests" "github.com/stretchr/testify/assert" ) func TestNewPager(t *testing.T) { - ctx, _ := tests.NewContext(c.Web, "/") + e := echo.New() + ctx, _ := tests.NewContext(e, "/") pgr := NewPager(ctx, 10) assert.Equal(t, 10, pgr.ItemsPerPage) assert.Equal(t, 1, pgr.Page) assert.Equal(t, 0, pgr.Items) assert.Equal(t, 0, pgr.Pages) - ctx, _ = tests.NewContext(c.Web, fmt.Sprintf("/abc?%s=%d", PageQueryKey, 2)) + ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", PageQueryKey, 2)) pgr = NewPager(ctx, 10) assert.Equal(t, 2, pgr.Page) - ctx, _ = tests.NewContext(c.Web, fmt.Sprintf("/abc?%s=%d", PageQueryKey, -2)) + ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", PageQueryKey, -2)) pgr = NewPager(ctx, 10) assert.Equal(t, 1, pgr.Page) } func TestPager_SetItems(t *testing.T) { - ctx, _ := tests.NewContext(c.Web, "/") + ctx, _ := tests.NewContext(echo.New(), "/") pgr := NewPager(ctx, 20) pgr.SetItems(100) assert.Equal(t, 100, pgr.Items) @@ -35,7 +37,7 @@ func TestPager_SetItems(t *testing.T) { } func TestPager_IsBeginning(t *testing.T) { - ctx, _ := tests.NewContext(c.Web, "/") + ctx, _ := tests.NewContext(echo.New(), "/") pgr := NewPager(ctx, 20) pgr.Pages = 10 assert.True(t, pgr.IsBeginning()) @@ -46,7 +48,7 @@ func TestPager_IsBeginning(t *testing.T) { } func TestPager_IsEnd(t *testing.T) { - ctx, _ := tests.NewContext(c.Web, "/") + ctx, _ := tests.NewContext(echo.New(), "/") pgr := NewPager(ctx, 20) pgr.Pages = 10 assert.False(t, pgr.IsEnd()) @@ -57,7 +59,7 @@ func TestPager_IsEnd(t *testing.T) { } func TestPager_GetOffset(t *testing.T) { - ctx, _ := tests.NewContext(c.Web, "/") + ctx, _ := tests.NewContext(echo.New(), "/") pgr := NewPager(ctx, 20) assert.Equal(t, 0, pgr.GetOffset()) pgr.Page = 2 diff --git a/pkg/services/container.go b/pkg/services/container.go index ca47e56..cb98bf7 100644 --- a/pkg/services/container.go +++ b/pkg/services/container.go @@ -9,6 +9,7 @@ import ( "entgo.io/ent/dialect" entsql "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/schema" + "github.com/mikestefanello/pagoda/pkg/funcmap" // Required by ent _ "github.com/jackc/pgx/v4/stdlib" @@ -182,7 +183,7 @@ func (c *Container) initAuth() { // initTemplateRenderer initializes the template renderer func (c *Container) initTemplateRenderer() { - c.TemplateRenderer = NewTemplateRenderer(c.Config, c.Web) + c.TemplateRenderer = NewTemplateRenderer(c.Config, c.Cache, funcmap.NewFuncMap(c.Web)) } // initMail initialize the mail client diff --git a/pkg/services/template_renderer.go b/pkg/services/template_renderer.go index 178a4f6..1d1abb7 100644 --- a/pkg/services/template_renderer.go +++ b/pkg/services/template_renderer.go @@ -6,14 +6,20 @@ import ( "fmt" "html/template" "io/fs" + "net/http" "sync" "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/config" - "github.com/mikestefanello/pagoda/pkg/funcmap" + "github.com/mikestefanello/pagoda/pkg/context" + "github.com/mikestefanello/pagoda/pkg/log" + "github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/templates" ) +// cachedPageGroup stores the cache group for cached pages +const cachedPageGroup = "page" + type ( // TemplateRenderer provides a flexible and easy to use method of rendering simple templates or complex sets of // templates while also providing caching and/or hot-reloading depending on your current environment @@ -26,6 +32,9 @@ type ( // config stores application configuration config *config.Config + + // cache stores the cache client + cache *CacheClient } // TemplateParsed is a wrapper around parsed templates which are stored in the TemplateRenderer cache @@ -51,14 +60,30 @@ type ( build *templateBuild renderer *TemplateRenderer } + + // CachedPage is what is used to store a rendered Page in the cache + CachedPage struct { + // URL stores the URL of the requested page + URL string + + // HTML stores the complete HTML of the rendered Page + HTML []byte + + // StatusCode stores the HTTP status code + StatusCode int + + // Headers stores the HTTP headers + Headers map[string]string + } ) // NewTemplateRenderer creates a new TemplateRenderer -func NewTemplateRenderer(cfg *config.Config, web *echo.Echo) *TemplateRenderer { +func NewTemplateRenderer(cfg *config.Config, cache *CacheClient, fm template.FuncMap) *TemplateRenderer { return &TemplateRenderer{ templateCache: sync.Map{}, - funcMap: funcmap.NewFuncMap(web), + funcMap: fm, config: cfg, + cache: cache, } } @@ -70,6 +95,138 @@ func (t *TemplateRenderer) Parse() *templateBuilder { } } +// 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). + Type(new(CachedPage)). + 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 e3e76ba..d9c6633 100644 --- a/pkg/services/template_renderer_test.go +++ b/pkg/services/template_renderer_test.go @@ -1,9 +1,17 @@ package services import ( + "context" + "fmt" + "net/http" + "net/http/httptest" "testing" + "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/config" + "github.com/mikestefanello/pagoda/pkg/htmx" + "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/tests" "github.com/mikestefanello/pagoda/templates" "github.com/stretchr/testify/assert" @@ -70,3 +78,121 @@ func TestTemplateRenderer(t *testing.T) { require.NotNil(t, buf) assert.Contains(t, buf.String(), "Please try again") } + +func TestTemplateRenderer_RenderPage(t *testing.T) { + setup := func() (echo.Context, *httptest.ResponseRecorder, page.Page) { + ctx, rec := tests.NewContext(c.Web, "/test/TestTemplateRenderer_RenderPage") + tests.InitSession(ctx) + + p := page.New(ctx) + p.Name = "home" + p.Layout = "main" + p.Cache.Enabled = false + p.Headers["A"] = "b" + p.Headers["C"] = "d" + p.StatusCode = http.StatusCreated + return ctx, rec, p + } + + t.Run("missing name", func(t *testing.T) { + // Rendering should fail if the Page has no name + ctx, _, p := setup() + p.Name = "" + err := c.TemplateRenderer.RenderPage(ctx, 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) + 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) + }) +} diff --git a/templates/pages/contact.gohtml b/templates/pages/contact.gohtml index 816a3ad..26f82d0 100644 --- a/templates/pages/contact.gohtml +++ b/templates/pages/contact.gohtml @@ -22,7 +22,7 @@ {{- else}} -
+
diff --git a/templates/pages/forgot-password.gohtml b/templates/pages/forgot-password.gohtml index 077320d..b058962 100644 --- a/templates/pages/forgot-password.gohtml +++ b/templates/pages/forgot-password.gohtml @@ -1,5 +1,5 @@ {{define "content"}} - +

Enter your email address and we'll email you a link that allows you to reset your password.

diff --git a/templates/pages/login.gohtml b/templates/pages/login.gohtml index 323c019..a2613bc 100644 --- a/templates/pages/login.gohtml +++ b/templates/pages/login.gohtml @@ -1,5 +1,5 @@ {{define "content"}} - + {{template "messages" .}}
diff --git a/templates/pages/register.gohtml b/templates/pages/register.gohtml index afac235..e55781f 100644 --- a/templates/pages/register.gohtml +++ b/templates/pages/register.gohtml @@ -1,5 +1,5 @@ {{define "content"}} - +