Skip to content

Commit

Permalink
Added redirect package.
Browse files Browse the repository at this point in the history
  • Loading branch information
mikestefanello committed Jun 16, 2024
1 parent 6730b6a commit ca22f54
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 82 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 26 additions & 8 deletions pkg/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -223,7 +224,10 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error {
}

msg.Success(ctx, fmt.Sprintf("Welcome back, <strong>%s</strong>. 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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand All @@ -293,15 +301,19 @@ 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.")

// 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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
26 changes: 0 additions & 26 deletions pkg/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down
48 changes: 0 additions & 48 deletions pkg/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
Expand Down
91 changes: 91 additions & 0 deletions pkg/redirect/redirect.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
77 changes: 77 additions & 0 deletions pkg/redirect/redirect_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}

0 comments on commit ca22f54

Please sign in to comment.