Move controller to the template renderer (#68)

This commit is contained in:
Mike Stefanello 2024-06-15 15:34:24 -04:00 committed by GitHub
parent 6a7070a729
commit 5531e0bec2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 654 additions and 679 deletions

View File

@ -45,15 +45,13 @@
* [Email verification](#email-verification) * [Email verification](#email-verification)
* [Routes](#routes) * [Routes](#routes)
* [Custom middleware](#custom-middleware) * [Custom middleware](#custom-middleware)
* [Controller](#controller)
* [Handlers](#handlers) * [Handlers](#handlers)
* [Errors](#errors) * [Errors](#errors)
* [Testing](#testing) * [Testing](#testing)
* [HTTP server](#http-server) * [HTTP server](#http-server)
* [Request / Request helpers](#request--response-helpers) * [Request / Request helpers](#request--response-helpers)
* [Goquery](#goquery) * [Goquery](#goquery)
* [Controller](#controller) * [Pages](#pages)
* [Page](#page)
* [Flash messaging](#flash-messaging) * [Flash messaging](#flash-messaging)
* [Pager](#pager) * [Pager](#pager)
* [CSRF](#csrf) * [CSRF](#csrf)
@ -200,8 +198,7 @@ A new container can be created and initialized via `services.NewContainer()`. It
### Dependency injection ### 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` 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.
so that the controller and the route using it have full, easy access to all services.
### Test dependencies ### 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. 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 ### 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. 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 ```go
type Example struct { type Example struct {
orm *ent.Client orm *ent.Client
controller.Controller *services.TemplateRenderer
} }
``` ```
@ -429,7 +420,7 @@ func init() {
```go ```go
func (e *Example) Init(c *services.Container) error { func (e *Example) Init(c *services.Container) error {
e.Controller = controller.NewController(c) e.TemplateRenderer = c.TemplateRenderer
e.orm = c.ORM e.orm = c.ORM
return nil return nil
} }
@ -506,13 +497,9 @@ assert.Len(t, h1.Nodes, 1)
assert.Equal(t, "About", h1.Text()) 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. 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`.
### 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.
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. 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 ```go
func (c *home) Get(ctx echo.Context) error { 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 ### 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. 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 ### 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. 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 ### 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`. 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: Then in your page:
```go ```go
page := controller.NewPage(ctx) p := page.New(ctx)
page.Form = form.Get[ContactForm](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. 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_: HTTP headers can be set either via the `Page` or the _context_:
```go ```go
page := controller.NewPage(ctx) p := page.New(ctx)
page.Headers["HeaderName"] = "header-value" p.Headers["HeaderName"] = "header-value"
``` ```
```go ```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_: The HTTP response status code can be set either via the `Page` or the _context_:
```go ```go
page := controller.NewPage(ctx) p := page.New(ctx)
page.StatusCode = http.StatusTooManyRequests p.StatusCode = http.StatusTooManyRequests
``` ```
```go ```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. 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 ```go
page := controller.NewPage(ctx) p := page.New(ctx)
page.Metatags.Description = "The page description." p.Metatags.Description = "The page description."
page.Metatags.Keywords = []string{"Go", "Software"} 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_. 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 ### 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: As an example, if you have route such as:
```go ```go
profile := Profile{Controller: ctr} e.GET("/user/profile/:user", handler.Get).Name = "user_profile"
e.GET("/user/profile/:user", profile.Get).Name = "user_profile"
``` ```
And you want to generate a URL in the template, you can: 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 ### 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 ```go
func (c *home) Get(ctx echo.Context) error { func (c *home) Get(ctx echo.Context) error {
page := controller.NewPage(ctx) p := page.New(ctx)
page.Layout = templates.LayoutMain p.Layout = templates.LayoutMain
page.Name = templates.PageHome p.Name = templates.PageHome
return c.RenderPage(ctx, page) return c.RenderPage(ctx, p)
} }
``` ```
@ -868,10 +854,10 @@ This will do the following:
- Include the [funcmap](#funcmap) - Include the [funcmap](#funcmap)
- Execute the parsed template with `data` being passed in to the templates - 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 ```go
buf, err = c.Container.TemplateRenderer. buf, err = c.TemplateRenderer.
Parse(). Parse().
Group("page"). Group("page").
Key(page.Name). 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
go func() { go func() {
if err := c.Tasks.StartScheduler(); err != nil { if err := c.Tasks.StartScheduler(); err != nil {
c.Web.Logger.Fatalf("scheduler shutdown: %v", err) log.Fatalf("scheduler shutdown: %v", err)
} }
}() }()
``` ```

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -18,13 +19,13 @@ func main() {
c := services.NewContainer() c := services.NewContainer()
defer func() { defer func() {
if err := c.Shutdown(); err != nil { if err := c.Shutdown(); err != nil {
c.Web.Logger.Fatal(err) log.Fatal(err)
} }
}() }()
// Build the router // Build the router
if err := handlers.BuildRouter(c); err != nil { 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 // Start the server
@ -40,7 +41,7 @@ func main() {
if c.Config.HTTP.TLS.Enabled { if c.Config.HTTP.TLS.Enabled {
certs, err := tls.LoadX509KeyPair(c.Config.HTTP.TLS.Certificate, c.Config.HTTP.TLS.Key) certs, err := tls.LoadX509KeyPair(c.Config.HTTP.TLS.Certificate, c.Config.HTTP.TLS.Key)
if err != nil { 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{ srv.TLSConfig = &tls.Config{
@ -49,14 +50,14 @@ func main() {
} }
if err := c.Web.StartServer(&srv); err != http.ErrServerClosed { 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 // Start the scheduler service to queue periodic tasks
go func() { go func() {
if err := c.Tasks.StartScheduler(); err != nil { 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) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := c.Web.Shutdown(ctx); err != nil { if err := c.Web.Shutdown(ctx); err != nil {
c.Web.Logger.Fatal(err) log.Fatal(err)
} }
} }

View File

@ -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))
}

View File

@ -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)
})
}

View File

@ -9,11 +9,11 @@ import (
"github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/ent/user" "github.com/mikestefanello/pagoda/ent/user"
"github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/controller"
"github.com/mikestefanello/pagoda/pkg/form" "github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/middleware" "github.com/mikestefanello/pagoda/pkg/middleware"
"github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/templates"
) )
@ -36,7 +36,7 @@ type (
auth *services.AuthClient auth *services.AuthClient
mail *services.MailClient mail *services.MailClient
orm *ent.Client orm *ent.Client
controller.Controller *services.TemplateRenderer
} }
forgotPasswordForm struct { forgotPasswordForm struct {
@ -69,51 +69,51 @@ func init() {
Register(new(Auth)) Register(new(Auth))
} }
func (c *Auth) Init(ct *services.Container) error { func (h *Auth) Init(c *services.Container) error {
c.Controller = controller.NewController(ct) h.TemplateRenderer = c.TemplateRenderer
c.orm = ct.ORM h.orm = c.ORM
c.auth = ct.Auth h.auth = c.Auth
c.mail = ct.Mail h.mail = c.Mail
return nil return nil
} }
func (c *Auth) Routes(g *echo.Group) { func (h *Auth) Routes(g *echo.Group) {
g.GET("/logout", c.Logout, middleware.RequireAuthentication()).Name = routeNameLogout g.GET("/logout", h.Logout, middleware.RequireAuthentication()).Name = routeNameLogout
g.GET("/email/verify/:token", c.VerifyEmail).Name = routeNameVerifyEmail g.GET("/email/verify/:token", h.VerifyEmail).Name = routeNameVerifyEmail
noAuth := g.Group("/user", middleware.RequireNoAuthentication()) noAuth := g.Group("/user", middleware.RequireNoAuthentication())
noAuth.GET("/login", c.LoginPage).Name = routeNameLogin noAuth.GET("/login", h.LoginPage).Name = routeNameLogin
noAuth.POST("/login", c.LoginSubmit).Name = routeNameLoginSubmit noAuth.POST("/login", h.LoginSubmit).Name = routeNameLoginSubmit
noAuth.GET("/register", c.RegisterPage).Name = routeNameRegister noAuth.GET("/register", h.RegisterPage).Name = routeNameRegister
noAuth.POST("/register", c.RegisterSubmit).Name = routeNameRegisterSubmit noAuth.POST("/register", h.RegisterSubmit).Name = routeNameRegisterSubmit
noAuth.GET("/password", c.ForgotPasswordPage).Name = routeNameForgotPassword noAuth.GET("/password", h.ForgotPasswordPage).Name = routeNameForgotPassword
noAuth.POST("/password", c.ForgotPasswordSubmit).Name = routeNameForgotPasswordSubmit noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routeNameForgotPasswordSubmit
resetGroup := noAuth.Group("/password/reset", resetGroup := noAuth.Group("/password/reset",
middleware.LoadUser(c.orm), middleware.LoadUser(h.orm),
middleware.LoadValidPasswordToken(c.auth), middleware.LoadValidPasswordToken(h.auth),
) )
resetGroup.GET("/token/:user/:password_token/:token", c.ResetPasswordPage).Name = routeNameResetPassword resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routeNameResetPassword
resetGroup.POST("/token/:user/:password_token/:token", c.ResetPasswordSubmit).Name = routeNameResetPasswordSubmit resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routeNameResetPasswordSubmit
} }
func (c *Auth) ForgotPasswordPage(ctx echo.Context) error { func (h *Auth) ForgotPasswordPage(ctx echo.Context) error {
page := controller.NewPage(ctx) p := page.New(ctx)
page.Layout = templates.LayoutAuth p.Layout = templates.LayoutAuth
page.Name = templates.PageForgotPassword p.Name = templates.PageForgotPassword
page.Title = "Forgot password" p.Title = "Forgot password"
page.Form = form.Get[forgotPasswordForm](ctx) 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 var input forgotPasswordForm
succeed := func() error { succeed := func() error {
form.Clear(ctx) 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.") 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) err := form.Submit(ctx, &input)
@ -121,13 +121,13 @@ func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
switch err.(type) { switch err.(type) {
case nil: case nil:
case validator.ValidationErrors: case validator.ValidationErrors:
return c.ForgotPasswordPage(ctx) return h.ForgotPasswordPage(ctx)
default: default:
return err return err
} }
// Attempt to load the user // Attempt to load the user
u, err := c.orm.User. u, err := h.orm.User.
Query(). Query().
Where(user.Email(strings.ToLower(input.Email))). Where(user.Email(strings.ToLower(input.Email))).
Only(ctx.Request().Context()) Only(ctx.Request().Context())
@ -137,13 +137,13 @@ func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
return succeed() return succeed()
case nil: case nil:
default: default:
return c.Fail(err, "error querying user during forgot password") return fail(err, "error querying user during forgot password")
} }
// Generate the token // Generate the token
token, pt, err := c.auth.GeneratePasswordResetToken(ctx, u.ID) token, pt, err := h.auth.GeneratePasswordResetToken(ctx, u.ID)
if err != nil { 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", log.Ctx(ctx).Info("generated password reset token",
@ -152,7 +152,7 @@ func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
// Email the user // Email the user
url := ctx.Echo().Reverse(routeNameResetPassword, u.ID, pt.ID, token) url := ctx.Echo().Reverse(routeNameResetPassword, u.ID, pt.ID, token)
err = c.mail. err = h.mail.
Compose(). Compose().
To(u.Email). To(u.Email).
Subject("Reset your password"). Subject("Reset your password").
@ -160,30 +160,30 @@ func (c *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
Send(ctx) Send(ctx)
if err != nil { if err != nil {
return c.Fail(err, "error sending password reset email") return fail(err, "error sending password reset email")
} }
return succeed() return succeed()
} }
func (c *Auth) LoginPage(ctx echo.Context) error { func (h *Auth) LoginPage(ctx echo.Context) error {
page := controller.NewPage(ctx) p := page.New(ctx)
page.Layout = templates.LayoutAuth p.Layout = templates.LayoutAuth
page.Name = templates.PageLogin p.Name = templates.PageLogin
page.Title = "Log in" p.Title = "Log in"
page.Form = form.Get[loginForm](ctx) 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 var input loginForm
authFailed := func() error { authFailed := func() error {
input.SetFieldError("Email", "") input.SetFieldError("Email", "")
input.SetFieldError("Password", "") input.SetFieldError("Password", "")
msg.Danger(ctx, "Invalid credentials. Please try again.") msg.Danger(ctx, "Invalid credentials. Please try again.")
return c.LoginPage(ctx) return h.LoginPage(ctx)
} }
err := form.Submit(ctx, &input) err := form.Submit(ctx, &input)
@ -191,13 +191,13 @@ func (c *Auth) LoginSubmit(ctx echo.Context) error {
switch err.(type) { switch err.(type) {
case nil: case nil:
case validator.ValidationErrors: case validator.ValidationErrors:
return c.LoginPage(ctx) return h.LoginPage(ctx)
default: default:
return err return err
} }
// Attempt to load the user // Attempt to load the user
u, err := c.orm.User. u, err := h.orm.User.
Query(). Query().
Where(user.Email(strings.ToLower(input.Email))). Where(user.Email(strings.ToLower(input.Email))).
Only(ctx.Request().Context()) Only(ctx.Request().Context())
@ -207,45 +207,45 @@ func (c *Auth) LoginSubmit(ctx echo.Context) error {
return authFailed() return authFailed()
case nil: case nil:
default: default:
return c.Fail(err, "error querying user during login") return fail(err, "error querying user during login")
} }
// Check if the password is correct // 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 { if err != nil {
return authFailed() return authFailed()
} }
// Log the user in // Log the user in
err = c.auth.Login(ctx, u.ID) err = h.auth.Login(ctx, u.ID)
if err != nil { 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, <strong>%s</strong>. You are now logged in.", u.Name)) msg.Success(ctx, fmt.Sprintf("Welcome back, <strong>%s</strong>. You are now logged in.", u.Name))
return c.Redirect(ctx, routeNameHome) return redirect(ctx, routeNameHome)
} }
func (c *Auth) Logout(ctx echo.Context) error { func (h *Auth) Logout(ctx echo.Context) error {
if err := c.auth.Logout(ctx); err == nil { if err := h.auth.Logout(ctx); err == nil {
msg.Success(ctx, "You have been logged out successfully.") msg.Success(ctx, "You have been logged out successfully.")
} else { } else {
msg.Danger(ctx, "An error occurred. Please try again.") 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 { func (h *Auth) RegisterPage(ctx echo.Context) error {
page := controller.NewPage(ctx) p := page.New(ctx)
page.Layout = templates.LayoutAuth p.Layout = templates.LayoutAuth
page.Name = templates.PageRegister p.Name = templates.PageRegister
page.Title = "Register" p.Title = "Register"
page.Form = form.Get[registerForm](ctx) 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 var input registerForm
err := form.Submit(ctx, &input) err := form.Submit(ctx, &input)
@ -253,19 +253,19 @@ func (c *Auth) RegisterSubmit(ctx echo.Context) error {
switch err.(type) { switch err.(type) {
case nil: case nil:
case validator.ValidationErrors: case validator.ValidationErrors:
return c.RegisterPage(ctx) return h.RegisterPage(ctx)
default: default:
return err return err
} }
// Hash the password // Hash the password
pwHash, err := c.auth.HashPassword(input.Password) pwHash, err := h.auth.HashPassword(input.Password)
if err != nil { if err != nil {
return c.Fail(err, "unable to hash password") return fail(err, "unable to hash password")
} }
// Attempt creating the user // Attempt creating the user
u, err := c.orm.User. u, err := h.orm.User.
Create(). Create().
SetName(input.Name). SetName(input.Name).
SetEmail(input.Email). SetEmail(input.Email).
@ -280,33 +280,33 @@ func (c *Auth) RegisterSubmit(ctx echo.Context) error {
) )
case *ent.ConstraintError: case *ent.ConstraintError:
msg.Warning(ctx, "A user with this email address already exists. Please log in.") msg.Warning(ctx, "A user with this email address already exists. Please log in.")
return c.Redirect(ctx, routeNameLogin) return redirect(ctx, routeNameLogin)
default: default:
return c.Fail(err, "unable to create user") return fail(err, "unable to create user")
} }
// Log the user in // Log the user in
err = c.auth.Login(ctx, u.ID) err = h.auth.Login(ctx, u.ID)
if err != nil { if err != nil {
log.Ctx(ctx).Error("unable to log user in", log.Ctx(ctx).Error("unable to log user in",
"error", err, "error", err,
"user_id", u.ID, "user_id", u.ID,
) )
msg.Info(ctx, "Your account has been created.") 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.") msg.Success(ctx, "Your account has been created. You are now logged in.")
// Send the verification email // 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 // Generate a token
token, err := c.auth.GenerateEmailVerificationToken(usr.Email) token, err := h.auth.GenerateEmailVerificationToken(usr.Email)
if err != nil { if err != nil {
log.Ctx(ctx).Error("unable to generate email verification token", log.Ctx(ctx).Error("unable to generate email verification token",
"user_id", usr.ID, "user_id", usr.ID,
@ -317,7 +317,7 @@ func (c *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) {
// Send the email // Send the email
url := ctx.Echo().Reverse(routeNameVerifyEmail, token) url := ctx.Echo().Reverse(routeNameVerifyEmail, token)
err = c.mail. err = h.mail.
Compose(). Compose().
To(usr.Email). To(usr.Email).
Subject("Confirm your email address"). 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.") msg.Info(ctx, "An email was sent to you to verify your email address.")
} }
func (c *Auth) ResetPasswordPage(ctx echo.Context) error { func (h *Auth) ResetPasswordPage(ctx echo.Context) error {
page := controller.NewPage(ctx) p := page.New(ctx)
page.Layout = templates.LayoutAuth p.Layout = templates.LayoutAuth
page.Name = templates.PageResetPassword p.Name = templates.PageResetPassword
page.Title = "Reset password" p.Title = "Reset password"
page.Form = form.Get[resetPasswordForm](ctx) 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 var input resetPasswordForm
err := form.Submit(ctx, &input) err := form.Submit(ctx, &input)
@ -353,15 +353,15 @@ func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error {
switch err.(type) { switch err.(type) {
case nil: case nil:
case validator.ValidationErrors: case validator.ValidationErrors:
return c.ResetPasswordPage(ctx) return h.ResetPasswordPage(ctx)
default: default:
return err return err
} }
// Hash the new password // Hash the new password
hash, err := c.auth.HashPassword(input.Password) hash, err := h.auth.HashPassword(input.Password)
if err != nil { if err != nil {
return c.Fail(err, "unable to hash password") return fail(err, "unable to hash password")
} }
// Get the requesting user // Get the requesting user
@ -374,28 +374,28 @@ func (c *Auth) ResetPasswordSubmit(ctx echo.Context) error {
Save(ctx.Request().Context()) Save(ctx.Request().Context())
if err != nil { 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 // Delete all password tokens for this user
err = c.auth.DeletePasswordTokens(ctx, usr.ID) err = h.auth.DeletePasswordTokens(ctx, usr.ID)
if err != nil { 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.") 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 var usr *ent.User
// Validate the token // Validate the token
token := ctx.Param("token") token := ctx.Param("token")
email, err := c.auth.ValidateEmailVerificationToken(token) email, err := h.auth.ValidateEmailVerificationToken(token)
if err != nil { if err != nil {
msg.Warning(ctx, "The link is either invalid or has expired.") 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 // 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 // Query to find a matching user, if needed
if usr == nil { if usr == nil {
usr, err = c.orm.User. usr, err = h.orm.User.
Query(). Query().
Where(user.Email(email)). Where(user.Email(email)).
Only(ctx.Request().Context()) Only(ctx.Request().Context())
if err != nil { 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()) Save(ctx.Request().Context())
if err != nil { 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.") msg.Success(ctx, "Your email has been successfully verified.")
return c.Redirect(ctx, routeNameHome) return redirect(ctx, routeNameHome)
} }

View File

@ -5,8 +5,8 @@ import (
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/controller"
"github.com/mikestefanello/pagoda/pkg/form" "github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/templates"
) )
@ -19,7 +19,7 @@ const (
type ( type (
Contact struct { Contact struct {
mail *services.MailClient mail *services.MailClient
controller.Controller *services.TemplateRenderer
} }
contactForm struct { contactForm struct {
@ -34,28 +34,28 @@ func init() {
Register(new(Contact)) Register(new(Contact))
} }
func (c *Contact) Init(ct *services.Container) error { func (h *Contact) Init(c *services.Container) error {
c.Controller = controller.NewController(ct) h.TemplateRenderer = c.TemplateRenderer
c.mail = ct.Mail h.mail = c.Mail
return nil return nil
} }
func (c *Contact) Routes(g *echo.Group) { func (h *Contact) Routes(g *echo.Group) {
g.GET("/contact", c.Page).Name = routeNameContact g.GET("/contact", h.Page).Name = routeNameContact
g.POST("/contact", c.Submit).Name = routeNameContactSubmit g.POST("/contact", h.Submit).Name = routeNameContactSubmit
} }
func (c *Contact) Page(ctx echo.Context) error { func (h *Contact) Page(ctx echo.Context) error {
page := controller.NewPage(ctx) p := page.New(ctx)
page.Layout = templates.LayoutMain p.Layout = templates.LayoutMain
page.Name = templates.PageContact p.Name = templates.PageContact
page.Title = "Contact us" p.Title = "Contact us"
page.Form = form.Get[contactForm](ctx) 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 var input contactForm
err := form.Submit(ctx, &input) err := form.Submit(ctx, &input)
@ -63,12 +63,12 @@ func (c *Contact) Submit(ctx echo.Context) error {
switch err.(type) { switch err.(type) {
case nil: case nil:
case validator.ValidationErrors: case validator.ValidationErrors:
return c.Page(ctx) return h.Page(ctx)
default: default:
return err return err
} }
err = c.mail. err = h.mail.
Compose(). Compose().
To(input.Email). To(input.Email).
Subject("Contact form submitted"). Subject("Contact form submitted").
@ -76,8 +76,8 @@ func (c *Contact) Submit(ctx echo.Context) error {
Send(ctx) Send(ctx)
if err != nil { 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)
} }

View File

@ -5,13 +5,14 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/controller"
"github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/templates"
) )
type Error struct { type Error struct {
controller.Controller *services.TemplateRenderer
} }
func (e *Error) Page(err error, ctx echo.Context) { 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 // Log the error
if code >= 500 { logger := log.Ctx(ctx)
log.Ctx(ctx).Error(err.Error()) switch {
case code >= 500:
logger.Error(err.Error())
case code >= 400:
logger.Warn(err.Error())
} }
// Render the error page // Render the error page
page := controller.NewPage(ctx) p := page.New(ctx)
page.Layout = templates.LayoutMain p.Layout = templates.LayoutMain
page.Name = templates.PageError p.Name = templates.PageError
page.Title = http.StatusText(code) p.Title = http.StatusText(code)
page.StatusCode = code p.StatusCode = code
page.HTMX.Request.Enabled = false 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", log.Ctx(ctx).Error("failed to render error page",
"error", err, "error", err,
) )

View File

@ -1,7 +1,11 @@
package handlers package handlers
import ( import (
"fmt"
"net/http"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/htmx"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
) )
@ -25,3 +29,24 @@ func Register(h Handler) {
func GetHandlers() []Handler { func GetHandlers() []Handler {
return handlers 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))
}

View File

@ -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)
}

View File

@ -5,7 +5,7 @@ import (
"html/template" "html/template"
"github.com/labstack/echo/v4" "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/pkg/services"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/templates"
) )
@ -17,7 +17,7 @@ const (
type ( type (
Pages struct { Pages struct {
controller.Controller *services.TemplateRenderer
} }
post struct { post struct {
@ -41,30 +41,30 @@ func init() {
Register(new(Pages)) Register(new(Pages))
} }
func (c *Pages) Init(ct *services.Container) error { func (h *Pages) Init(c *services.Container) error {
c.Controller = controller.NewController(ct) h.TemplateRenderer = c.TemplateRenderer
return nil return nil
} }
func (c *Pages) Routes(g *echo.Group) { func (h *Pages) Routes(g *echo.Group) {
g.GET("/", c.Home).Name = routeNameHome g.GET("/", h.Home).Name = routeNameHome
g.GET("/about", c.About).Name = routeNameAbout g.GET("/about", h.About).Name = routeNameAbout
} }
func (c *Pages) Home(ctx echo.Context) error { func (h *Pages) Home(ctx echo.Context) error {
page := controller.NewPage(ctx) p := page.New(ctx)
page.Layout = templates.LayoutMain p.Layout = templates.LayoutMain
page.Name = templates.PageHome p.Name = templates.PageHome
page.Metatags.Description = "Welcome to the homepage." p.Metatags.Description = "Welcome to the homepage."
page.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"} p.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"}
page.Pager = controller.NewPager(ctx, 4) p.Pager = page.NewPager(ctx, 4)
page.Data = c.fetchPosts(&page.Pager) 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 // 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) pager.SetItems(20)
posts := make([]post, 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] return posts[pager.GetOffset() : pager.GetOffset()+pager.ItemsPerPage]
} }
func (c *Pages) About(ctx echo.Context) error { func (h *Pages) About(ctx echo.Context) error {
page := controller.NewPage(ctx) p := page.New(ctx)
page.Layout = templates.LayoutMain p.Layout = templates.LayoutMain
page.Name = templates.PageAbout p.Name = templates.PageAbout
page.Title = "About" p.Title = "About"
// This page will be cached! // This page will be cached!
page.Cache.Enabled = true p.Cache.Enabled = true
page.Cache.Tags = []string{"page_about", "page:list"} 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 // 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 // even though you wouldn't normally send markup like this
page.Data = aboutData{ p.Data = aboutData{
ShowCacheWarning: true, ShowCacheWarning: true,
FrontendTabs: []aboutTab{ FrontendTabs: []aboutTab{
{ {
@ -117,5 +117,5 @@ func (c *Pages) About(ctx echo.Context) error {
}, },
} }
return c.RenderPage(ctx, page) return h.RenderPage(ctx, p)
} }

View File

@ -7,7 +7,6 @@ import (
"github.com/labstack/echo-contrib/session" "github.com/labstack/echo-contrib/session"
echomw "github.com/labstack/echo/v4/middleware" echomw "github.com/labstack/echo/v4/middleware"
"github.com/mikestefanello/pagoda/config" "github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/controller"
"github.com/mikestefanello/pagoda/pkg/middleware" "github.com/mikestefanello/pagoda/pkg/middleware"
"github.com/mikestefanello/pagoda/pkg/services" "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))), session.Middleware(sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))),
middleware.LoadAuthenticatedUser(c.Auth), middleware.LoadAuthenticatedUser(c.Auth),
middleware.ServeCachedPage(c.Cache), middleware.ServeCachedPage(c.TemplateRenderer),
echomw.CSRFWithConfig(echomw.CSRFConfig{ echomw.CSRFWithConfig(echomw.CSRFConfig{
TokenLookup: "form:csrf", TokenLookup: "form:csrf",
}), }),
) )
// Error handler // Error handler
err := Error{Controller: controller.NewController(c)} err := Error{c.TemplateRenderer}
c.Web.HTTPErrorHandler = err.Page c.Web.HTTPErrorHandler = err.Page
// Initialize and register all handlers // Initialize and register all handlers

View File

@ -5,7 +5,7 @@ import (
"math/rand" "math/rand"
"github.com/labstack/echo/v4" "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/pkg/services"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/templates"
) )
@ -14,7 +14,7 @@ const routeNameSearch = "search"
type ( type (
Search struct { Search struct {
controller.Controller *services.TemplateRenderer
} }
searchResult struct { searchResult struct {
@ -27,19 +27,19 @@ func init() {
Register(new(Search)) Register(new(Search))
} }
func (c *Search) Init(ct *services.Container) error { func (h *Search) Init(c *services.Container) error {
c.Controller = controller.NewController(ct) h.TemplateRenderer = c.TemplateRenderer
return nil return nil
} }
func (c *Search) Routes(g *echo.Group) { func (h *Search) Routes(g *echo.Group) {
g.GET("/search", c.Page).Name = routeNameSearch g.GET("/search", h.Page).Name = routeNameSearch
} }
func (c *Search) Page(ctx echo.Context) error { func (h *Search) Page(ctx echo.Context) error {
page := controller.NewPage(ctx) p := page.New(ctx)
page.Layout = templates.LayoutMain p.Layout = templates.LayoutMain
page.Name = templates.PageSearch p.Name = templates.PageSearch
// Fake search results // Fake search results
var results []searchResult 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)
} }

View File

@ -7,6 +7,7 @@ import (
"github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
@ -20,11 +21,10 @@ func LoadAuthenticatedUser(authClient *services.AuthClient) echo.MiddlewareFunc
u, err := authClient.GetAuthenticatedUser(c) u, err := authClient.GetAuthenticatedUser(c)
switch err.(type) { switch err.(type) {
case *ent.NotFoundError: case *ent.NotFoundError:
c.Logger().Warn("auth user not found") log.Ctx(c).Warn("auth user not found")
case services.NotAuthenticatedError: case services.NotAuthenticatedError:
case nil: case nil:
c.Set(context.AuthenticatedUserKey, u) c.Set(context.AuthenticatedUserKey, u)
c.Logger().Infof("auth user loaded in to context: %d", u.ID)
default: default:
return echo.NewHTTPError( return echo.NewHTTPError(
http.StatusInternalServerError, http.StatusInternalServerError,

View File

@ -7,83 +7,56 @@ import (
"time" "time"
"github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
libstore "github.com/eko/gocache/lib/v4/store" libstore "github.com/eko/gocache/lib/v4/store"
"github.com/labstack/echo/v4" "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 // 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. // 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. // 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(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(ctx echo.Context) error {
// Skip non GET requests // Skip non GET requests
if c.Request().Method != http.MethodGet { if ctx.Request().Method != http.MethodGet {
return next(c) return next(ctx)
} }
// Skip if the user is authenticated // Skip if the user is authenticated
if c.Get(context.AuthenticatedUserKey) != nil { if ctx.Get(context.AuthenticatedUserKey) != nil {
return next(c) return next(ctx)
} }
// Attempt to load from cache // Attempt to load from cache
res, err := ch. page, err := t.GetCachedPage(ctx, ctx.Request().URL.String())
Get().
Group(CachedPageGroup).
Key(c.Request().URL.String()).
Type(new(CachedPage)).
Fetch(c.Request().Context())
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, &libstore.NotFound{}): case errors.Is(err, &libstore.NotFound{}):
c.Logger().Info("no cached page found")
case context.IsCanceledError(err): case context.IsCanceledError(err):
return nil return nil
default: default:
c.Logger().Errorf("failed getting cached page: %v", err) log.Ctx(ctx).Error("failed getting cached page",
"error", err,
)
} }
return next(c) return next(ctx)
}
page, ok := res.(*CachedPage)
if !ok {
c.Logger().Errorf("failed casting cached page")
return next(c)
} }
// Set any headers // Set any headers
if page.Headers != nil { if page.Headers != nil {
for k, v := range page.Headers { 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 // CacheControl sets a Cache-Control header with a given max age
func CacheControl(maxAge time.Duration) echo.MiddlewareFunc { func CacheControl(maxAge time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(ctx echo.Context) error {
v := "no-cache, no-store" v := "no-cache, no-store"
if maxAge > 0 { if maxAge > 0 {
v = fmt.Sprintf("public, max-age=%.0f", maxAge.Seconds()) v = fmt.Sprintf("public, max-age=%.0f", maxAge.Seconds())
} }
c.Response().Header().Set("Cache-Control", v) ctx.Response().Header().Set("Cache-Control", v)
return next(c) return next(ctx)
} }
} }
} }

View File

@ -1,12 +1,13 @@
package middleware package middleware
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
"time" "time"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/tests" "github.com/mikestefanello/pagoda/pkg/tests"
"github.com/mikestefanello/pagoda/templates"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -15,38 +16,34 @@ import (
func TestServeCachedPage(t *testing.T) { func TestServeCachedPage(t *testing.T) {
// Cache a page // Cache a page
cp := CachedPage{ ctx, rec := tests.NewContext(c.Web, "/cache")
URL: "/cache", p := page.New(ctx)
HTML: []byte("html"), p.Layout = templates.LayoutHTMX
Headers: make(map[string]string), p.Name = templates.PageHome
StatusCode: http.StatusCreated, p.Cache.Enabled = true
} p.Cache.Expiration = time.Minute
cp.Headers["a"] = "b" p.StatusCode = http.StatusCreated
cp.Headers["c"] = "d" p.Headers["a"] = "b"
p.Headers["c"] = "d"
err := c.Cache. err := c.TemplateRenderer.RenderPage(ctx, p)
Set(). output := rec.Body.Bytes()
Group(CachedPageGroup).
Key(cp.URL).
Data(cp).
Save(context.Background())
require.NoError(t, err) require.NoError(t, err)
// Request the URL of the cached page // Request the URL of the cached page
ctx, rec := tests.NewContext(c.Web, cp.URL) ctx, rec = tests.NewContext(c.Web, "/cache")
err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.Cache)) err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer))
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, cp.StatusCode, ctx.Response().Status) assert.Equal(t, p.StatusCode, ctx.Response().Status)
assert.Equal(t, cp.Headers["a"], ctx.Response().Header().Get("a")) assert.Equal(t, p.Headers["a"], ctx.Response().Header().Get("a"))
assert.Equal(t, cp.Headers["c"], ctx.Response().Header().Get("c")) assert.Equal(t, p.Headers["c"], ctx.Response().Header().Get("c"))
assert.Equal(t, cp.HTML, rec.Body.Bytes()) assert.Equal(t, output, rec.Body.Bytes())
// Login and try again // Login and try again
tests.InitSession(ctx) tests.InitSession(ctx)
err = c.Auth.Login(ctx, usr.ID) err = c.Auth.Login(ctx, usr.ID)
require.NoError(t, err) require.NoError(t, err)
_ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth)) _ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth))
err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.Cache)) err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer))
assert.Nil(t, err) assert.Nil(t, err)
} }

View File

@ -1,4 +1,4 @@
package controller package page
import ( import (
"html/template" "html/template"
@ -7,7 +7,6 @@ import (
"github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/htmx" "github.com/mikestefanello/pagoda/pkg/htmx"
"github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/templates"
@ -17,9 +16,9 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
// Page consists of all data that will be used to render a page response for a given controller. // 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 controller to render a Page on a route, this is the common data // 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 controllers to share // 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 // functionality both on the back and frontend. The Page can be expanded to include anything else
// your app wants to support. // your app wants to support.
// Methods on this page also then become available in the templates, which can be more useful than // 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 URL string
// Data stores whatever additional data that needs to be passed to the templates. // 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 Data any
// Form stores a struct that represents a form on the page. // 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 // 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 // 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. // 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. // 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 // New creates and initiatizes a new Page for a given request context
func NewPage(ctx echo.Context) Page { func New(ctx echo.Context) Page {
p := Page{ p := Page{
Context: ctx, Context: ctx,
Path: ctx.Request().URL.Path, Path: ctx.Request().URL.Path,

View File

@ -1,21 +1,23 @@
package controller package page
import ( import (
"net/http" "net/http"
"testing" "testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/tests" "github.com/mikestefanello/pagoda/pkg/tests"
echomw "github.com/labstack/echo/v4/middleware" echomw "github.com/labstack/echo/v4/middleware"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestNewPage(t *testing.T) { func TestNew(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/") e := echo.New()
p := NewPage(ctx) ctx, _ := tests.NewContext(e, "/")
p := New(ctx)
assert.Same(t, ctx, p.Context) assert.Same(t, ctx, p.Context)
assert.Equal(t, "/", p.Path) assert.Equal(t, "/", p.Path)
assert.Equal(t, "/", p.URL) assert.Equal(t, "/", p.URL)
@ -28,12 +30,13 @@ func TestNewPage(t *testing.T) {
assert.Empty(t, p.RequestID) assert.Empty(t, p.RequestID)
assert.False(t, p.Cache.Enabled) assert.False(t, p.Cache.Enabled)
ctx, _ = tests.NewContext(c.Web, "/abc?def=123") ctx, _ = tests.NewContext(e, "/abc?def=123")
usr, err := tests.CreateUser(c.ORM) usr := &ent.User{
require.NoError(t, err) ID: 1,
}
ctx.Set(context.AuthenticatedUserKey, usr) ctx.Set(context.AuthenticatedUserKey, usr)
ctx.Set(echomw.DefaultCSRFConfig.ContextKey, "csrf") ctx.Set(echomw.DefaultCSRFConfig.ContextKey, "csrf")
p = NewPage(ctx) p = New(ctx)
assert.Equal(t, "/abc", p.Path) assert.Equal(t, "/abc", p.Path)
assert.Equal(t, "/abc?def=123", p.URL) assert.Equal(t, "/abc?def=123", p.URL)
assert.False(t, p.IsHome) assert.False(t, p.IsHome)
@ -43,9 +46,9 @@ func TestNewPage(t *testing.T) {
} }
func TestPage_GetMessages(t *testing.T) { func TestPage_GetMessages(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/") ctx, _ := tests.NewContext(echo.New(), "/")
tests.InitSession(ctx) tests.InitSession(ctx)
p := NewPage(ctx) p := New(ctx)
// Set messages // Set messages
msgTests := make(map[msg.Type][]string) msgTests := make(map[msg.Type][]string)

View File

@ -1,4 +1,4 @@
package controller package page
import ( import (
"math" "math"

View File

@ -1,33 +1,35 @@
package controller package page
import ( import (
"fmt" "fmt"
"testing" "testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/tests" "github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestNewPager(t *testing.T) { func TestNewPager(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/") e := echo.New()
ctx, _ := tests.NewContext(e, "/")
pgr := NewPager(ctx, 10) pgr := NewPager(ctx, 10)
assert.Equal(t, 10, pgr.ItemsPerPage) assert.Equal(t, 10, pgr.ItemsPerPage)
assert.Equal(t, 1, pgr.Page) assert.Equal(t, 1, pgr.Page)
assert.Equal(t, 0, pgr.Items) assert.Equal(t, 0, pgr.Items)
assert.Equal(t, 0, pgr.Pages) 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) pgr = NewPager(ctx, 10)
assert.Equal(t, 2, pgr.Page) 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) pgr = NewPager(ctx, 10)
assert.Equal(t, 1, pgr.Page) assert.Equal(t, 1, pgr.Page)
} }
func TestPager_SetItems(t *testing.T) { func TestPager_SetItems(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/") ctx, _ := tests.NewContext(echo.New(), "/")
pgr := NewPager(ctx, 20) pgr := NewPager(ctx, 20)
pgr.SetItems(100) pgr.SetItems(100)
assert.Equal(t, 100, pgr.Items) assert.Equal(t, 100, pgr.Items)
@ -35,7 +37,7 @@ func TestPager_SetItems(t *testing.T) {
} }
func TestPager_IsBeginning(t *testing.T) { func TestPager_IsBeginning(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/") ctx, _ := tests.NewContext(echo.New(), "/")
pgr := NewPager(ctx, 20) pgr := NewPager(ctx, 20)
pgr.Pages = 10 pgr.Pages = 10
assert.True(t, pgr.IsBeginning()) assert.True(t, pgr.IsBeginning())
@ -46,7 +48,7 @@ func TestPager_IsBeginning(t *testing.T) {
} }
func TestPager_IsEnd(t *testing.T) { func TestPager_IsEnd(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/") ctx, _ := tests.NewContext(echo.New(), "/")
pgr := NewPager(ctx, 20) pgr := NewPager(ctx, 20)
pgr.Pages = 10 pgr.Pages = 10
assert.False(t, pgr.IsEnd()) assert.False(t, pgr.IsEnd())
@ -57,7 +59,7 @@ func TestPager_IsEnd(t *testing.T) {
} }
func TestPager_GetOffset(t *testing.T) { func TestPager_GetOffset(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/") ctx, _ := tests.NewContext(echo.New(), "/")
pgr := NewPager(ctx, 20) pgr := NewPager(ctx, 20)
assert.Equal(t, 0, pgr.GetOffset()) assert.Equal(t, 0, pgr.GetOffset())
pgr.Page = 2 pgr.Page = 2

View File

@ -9,6 +9,7 @@ import (
"entgo.io/ent/dialect" "entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql" entsql "entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/schema" "entgo.io/ent/dialect/sql/schema"
"github.com/mikestefanello/pagoda/pkg/funcmap"
// Required by ent // Required by ent
_ "github.com/jackc/pgx/v4/stdlib" _ "github.com/jackc/pgx/v4/stdlib"
@ -182,7 +183,7 @@ func (c *Container) initAuth() {
// initTemplateRenderer initializes the template renderer // initTemplateRenderer initializes the template renderer
func (c *Container) initTemplateRenderer() { 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 // initMail initialize the mail client

View File

@ -6,14 +6,20 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"io/fs" "io/fs"
"net/http"
"sync" "sync"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/config" "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" "github.com/mikestefanello/pagoda/templates"
) )
// cachedPageGroup stores the cache group for cached pages
const cachedPageGroup = "page"
type ( type (
// TemplateRenderer provides a flexible and easy to use method of rendering simple templates or complex sets of // 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 // templates while also providing caching and/or hot-reloading depending on your current environment
@ -26,6 +32,9 @@ type (
// config stores application configuration // config stores application configuration
config *config.Config config *config.Config
// cache stores the cache client
cache *CacheClient
} }
// TemplateParsed is a wrapper around parsed templates which are stored in the TemplateRenderer cache // TemplateParsed is a wrapper around parsed templates which are stored in the TemplateRenderer cache
@ -51,14 +60,30 @@ type (
build *templateBuild build *templateBuild
renderer *TemplateRenderer 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 // 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{ return &TemplateRenderer{
templateCache: sync.Map{}, templateCache: sync.Map{},
funcMap: funcmap.NewFuncMap(web), funcMap: fm,
config: cfg, 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 // getCacheKey gets a cache key for a given group and ID
func (t *TemplateRenderer) getCacheKey(group, key string) string { func (t *TemplateRenderer) getCacheKey(group, key string) string {
if group != "" { if group != "" {

View File

@ -1,9 +1,17 @@
package services package services
import ( import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing" "testing"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/config" "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/mikestefanello/pagoda/templates"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -70,3 +78,121 @@ func TestTemplateRenderer(t *testing.T) {
require.NotNil(t, buf) require.NotNil(t, buf)
assert.Contains(t, buf.String(), "Please try again") 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)
})
}

View File

@ -22,7 +22,7 @@
</div> </div>
</article> </article>
{{- else}} {{- else}}
<form id="contact" method="post" hx-post="{{url "contact.post"}}"> <form id="contact" method="post" hx-post="{{url "contact.submit"}}">
<div class="field"> <div class="field">
<label for="email" class="label">Email address</label> <label for="email" class="label">Email address</label>
<div class="control"> <div class="control">

View File

@ -1,5 +1,5 @@
{{define "content"}} {{define "content"}}
<form method="post" hx-boost="true" action="{{url "forgot_password.post"}}"> <form method="post" hx-boost="true" action="{{url "forgot_password.submit"}}">
<div class="content"> <div class="content">
<p>Enter your email address and we'll email you a link that allows you to reset your password.</p> <p>Enter your email address and we'll email you a link that allows you to reset your password.</p>
</div> </div>

View File

@ -1,5 +1,5 @@
{{define "content"}} {{define "content"}}
<form method="post" hx-boost="true" action="{{url "login.post"}}"> <form method="post" hx-boost="true" action="{{url "login.submit"}}">
{{template "messages" .}} {{template "messages" .}}
<div class="field"> <div class="field">
<label for="email" class="label">Email address</label> <label for="email" class="label">Email address</label>

View File

@ -1,5 +1,5 @@
{{define "content"}} {{define "content"}}
<form method="post" hx-boost="true" action="{{url "register.post"}}"> <form method="post" hx-boost="true" action="{{url "register.submit"}}">
<div class="field"> <div class="field">
<label for="name" class="label">Name</label> <label for="name" class="label">Name</label>
<div class="control"> <div class="control">