diff --git a/README.md b/README.md index 7a6b021..897d59e 100644 --- a/README.md +++ b/README.md @@ -455,6 +455,20 @@ Routes can return errors to indicate that something wrong happened. Ideally, the The [error handler](https://echo.labstack.com/guide/error-handling/) is set to a provided route `pkg/handlers/error.go` in the `BuildRouter()` function. That means that if any middleware or route return an error, the request gets routed there. This route conveniently constructs and renders a `Page` which uses the template `templates/pages/error.go`. The status code is passed to the template so you can easily alter the markup depending on the error type. +### Redirects + +The `pkg/redirect` package makes it easy to perform redirects, especially if you provide names for your routes. The `Redirect` type provides the ability to chain redirect options and also supports automatically handling HTMX redirects for boosted requests. + +For example, if your route name is `user_profile` with a URL pattern of `/user/profile/:id`, you can perform a redirect by doing: + +```go +return redirect.New(ctx). + Route("user_profile"). + Params(userID). + Query(queryParams). + Go() +``` + ### Testing Since most of your web application logic will live in your routes, being able to easily test them is important. The following aims to help facilitate that. diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index d608104..b7f9edd 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -14,6 +14,7 @@ import ( "github.com/mikestefanello/pagoda/pkg/middleware" "github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/redirect" "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/templates" ) @@ -223,7 +224,10 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error { } msg.Success(ctx, fmt.Sprintf("Welcome back, %s. You are now logged in.", u.Name)) - return redirect(ctx, routeNameHome) + + return redirect.New(ctx). + Route(routeNameHome). + Go() } func (h *Auth) Logout(ctx echo.Context) error { @@ -232,7 +236,9 @@ func (h *Auth) Logout(ctx echo.Context) error { } else { msg.Danger(ctx, "An error occurred. Please try again.") } - return redirect(ctx, routeNameHome) + return redirect.New(ctx). + Route(routeNameHome). + Go() } func (h *Auth) RegisterPage(ctx echo.Context) error { @@ -280,7 +286,9 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error { ) case *ent.ConstraintError: msg.Warning(ctx, "A user with this email address already exists. Please log in.") - return redirect(ctx, routeNameLogin) + return redirect.New(ctx). + Route(routeNameLogin). + Go() default: return fail(err, "unable to create user") } @@ -293,7 +301,9 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error { "user_id", u.ID, ) msg.Info(ctx, "Your account has been created.") - return redirect(ctx, routeNameLogin) + return redirect.New(ctx). + Route(routeNameLogin). + Go() } msg.Success(ctx, "Your account has been created. You are now logged in.") @@ -301,7 +311,9 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error { // Send the verification email h.sendVerificationEmail(ctx, u) - return redirect(ctx, routeNameHome) + return redirect.New(ctx). + Route(routeNameHome). + Go() } func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { @@ -384,7 +396,9 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error { } msg.Success(ctx, "Your password has been updated.") - return redirect(ctx, routeNameLogin) + return redirect.New(ctx). + Route(routeNameLogin). + Go() } func (h *Auth) VerifyEmail(ctx echo.Context) error { @@ -395,7 +409,9 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error { email, err := h.auth.ValidateEmailVerificationToken(token) if err != nil { msg.Warning(ctx, "The link is either invalid or has expired.") - return redirect(ctx, routeNameHome) + return redirect.New(ctx). + Route(routeNameHome). + Go() } // Check if it matches the authenticated user @@ -432,5 +448,7 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error { } msg.Success(ctx, "Your email has been successfully verified.") - return redirect(ctx, routeNameHome) + return redirect.New(ctx). + Route(routeNameHome). + Go() } diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go index 1a8f0e8..d5c1c0d 100644 --- a/pkg/handlers/handlers.go +++ b/pkg/handlers/handlers.go @@ -3,10 +3,8 @@ package handlers import ( "fmt" "net/http" - "net/url" "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/pkg/htmx" "github.com/mikestefanello/pagoda/pkg/services" ) @@ -31,30 +29,6 @@ func GetHandlers() []Handler { return handlers } -// redirect redirects to a given route by name with optional route parameters -func redirect(ctx echo.Context, routeName string, routeParams ...any) error { - return doRedirect(ctx, ctx.Echo().Reverse(routeName, routeParams...)) -} - -// redirectWithQuery redirects to a given route by name with query parameters and optional route parameters -func redirectWithQuery(ctx echo.Context, query url.Values, routeName string, routeParams ...any) error { - dest := fmt.Sprintf("%s?%s", ctx.Echo().Reverse(routeName, routeParams...), query.Encode()) - return doRedirect(ctx, dest) -} - -// doRedirect performs a redirect to a given URL -func doRedirect(ctx echo.Context, url string) error { - 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 diff --git a/pkg/handlers/handlers_test.go b/pkg/handlers/handlers_test.go index 01f71dc..d551e4b 100644 --- a/pkg/handlers/handlers_test.go +++ b/pkg/handlers/handlers_test.go @@ -3,12 +3,9 @@ package handlers import ( "errors" "net/http" - "net/url" "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" ) @@ -23,51 +20,6 @@ func TestGetSetHandlers(t *testing.T) { 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("no query", 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("no query htmx", 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)) - }) - - t.Run("query", func(t *testing.T) { - ctx, _ := tests.NewContext(c.Web, "/abc") - q := url.Values{} - q.Add("a", "1") - q.Add("b", "2") - err := redirectWithQuery(ctx, q, "redirect-test", "one", "two") - require.NoError(t, err) - assert.Equal(t, "/path/one/and/two?a=1&b=2", ctx.Response().Header().Get(echo.HeaderLocation)) - assert.Equal(t, http.StatusFound, ctx.Response().Status) - }) - - t.Run("query htmx", func(t *testing.T) { - ctx, _ := tests.NewContext(c.Web, "/abc") - ctx.Request().Header.Set(htmx.HeaderBoosted, "true") - q := url.Values{} - q.Add("a", "1") - q.Add("b", "2") - err := redirectWithQuery(ctx, q, "redirect-test", "one", "two") - require.NoError(t, err) - assert.Equal(t, "/path/one/and/two?a=1&b=2", ctx.Response().Header().Get(htmx.HeaderRedirect)) - assert.Equal(t, http.StatusFound, ctx.Response().Status) - }) -} - func TestFail(t *testing.T) { err := fail(errors.New("err message"), "log message") require.IsType(t, new(echo.HTTPError), err) diff --git a/pkg/redirect/redirect.go b/pkg/redirect/redirect.go new file mode 100644 index 0000000..3e97179 --- /dev/null +++ b/pkg/redirect/redirect.go @@ -0,0 +1,91 @@ +package redirect + +import ( + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/htmx" +) + +// Redirect is a helper to perform HTTP redirects. +type Redirect struct { + ctx echo.Context + url string + routeName string + routeParams []any + status int + query url.Values +} + +// New initializes a new Redirect +func New(ctx echo.Context) *Redirect { + return &Redirect{ + ctx: ctx, + status: http.StatusFound, + } +} + +// Route sets the route name to redirect to. +// Use either this or URL() +func (r *Redirect) Route(name string) *Redirect { + r.routeName = name + return r +} + +// Params sets the route params +func (r *Redirect) Params(params ...any) *Redirect { + r.routeParams = params + return r +} + +// StatusCode sets the HTTP status code which defaults to http.StatusFound. +// Does not apply to HTMX redirects. +func (r *Redirect) StatusCode(code int) *Redirect { + r.status = code + return r +} + +// Query sets a URL query +func (r *Redirect) Query(query url.Values) *Redirect { + r.query = query + return r +} + +// URL sets the URL to redirect to +// Use either this or Route() +func (r *Redirect) URL(url string) *Redirect { + r.url = url + return r +} + +// Go performs the redirect +// If the request is HTMX boosted, an HTMX redirect will be performed instead of an HTTP redirect +func (r *Redirect) Go() error { + if r.routeName == "" && r.url == "" { + return errors.New("no redirect provided") + } + + var dest string + if r.url != "" { + dest = r.url + } else { + dest = r.ctx.Echo().Reverse(r.routeName, r.routeParams...) + } + + if len(r.query) > 0 { + dest = fmt.Sprintf("%s?%s", dest, r.query.Encode()) + } + + if htmx.GetRequest(r.ctx).Boosted { + htmx.Response{ + Redirect: dest, + }.Apply(r.ctx) + + return nil + } else { + return r.ctx.Redirect(r.status, dest) + } +} diff --git a/pkg/redirect/redirect_test.go b/pkg/redirect/redirect_test.go new file mode 100644 index 0000000..4685f2a --- /dev/null +++ b/pkg/redirect/redirect_test.go @@ -0,0 +1,77 @@ +package redirect + +import ( + "net/http" + "net/url" + "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 TestRedirect(t *testing.T) { + e := echo.New() + e.GET("/path/:first/and/:second", func(c echo.Context) error { + return nil + }).Name = "test" + + redirect := func() (*Redirect, echo.Context) { + ctx, _ := tests.NewContext(e, "/") + return New(ctx), ctx + } + + t.Run("route", func(t *testing.T) { + q := url.Values{} + q.Add("a", "1") + q.Add("b", "2") + r, ctx := redirect() + r.Route("test") + r.Params("one", "two") + r.Query(q) + r.StatusCode(http.StatusTemporaryRedirect) + require.NoError(t, r.Go()) + assert.Equal(t, "/path/one/and/two?a=1&b=2", ctx.Response().Header().Get(echo.HeaderLocation)) + assert.Equal(t, http.StatusTemporaryRedirect, ctx.Response().Status) + }) + + t.Run("route htmx", func(t *testing.T) { + q := url.Values{} + q.Add("a", "1") + q.Add("b", "2") + r, ctx := redirect() + ctx.Request().Header.Set(htmx.HeaderBoosted, "true") + r.Route("test") + r.Params("one", "two") + r.Query(q) + require.NoError(t, r.Go()) + assert.Equal(t, "/path/one/and/two?a=1&b=2", ctx.Response().Header().Get(htmx.HeaderRedirect)) + }) + + t.Run("url", func(t *testing.T) { + q := url.Values{} + q.Add("a", "1") + q.Add("b", "2") + r, ctx := redirect() + r.URL("https://localhost.dev") + r.Query(q) + r.StatusCode(http.StatusTemporaryRedirect) + require.NoError(t, r.Go()) + assert.Equal(t, "https://localhost.dev?a=1&b=2", ctx.Response().Header().Get(echo.HeaderLocation)) + assert.Equal(t, http.StatusTemporaryRedirect, ctx.Response().Status) + }) + + t.Run("url htmx", func(t *testing.T) { + q := url.Values{} + q.Add("a", "1") + q.Add("b", "2") + r, ctx := redirect() + ctx.Request().Header.Set(htmx.HeaderBoosted, "true") + r.URL("https://localhost.dev") + r.Query(q) + require.NoError(t, r.Go()) + assert.Equal(t, "https://localhost.dev?a=1&b=2", ctx.Response().Header().Get(htmx.HeaderRedirect)) + }) +}