Added redirect package.

This commit is contained in:
mikestefanello 2024-06-16 11:30:11 -04:00
parent 6730b6a319
commit ca22f54c89
6 changed files with 208 additions and 82 deletions

View File

@ -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. 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 ### 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. 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.

View File

@ -14,6 +14,7 @@ import (
"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/page"
"github.com/mikestefanello/pagoda/pkg/redirect"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/templates"
) )
@ -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)) 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 { func (h *Auth) Logout(ctx echo.Context) error {
@ -232,7 +236,9 @@ func (h *Auth) Logout(ctx echo.Context) error {
} else { } else {
msg.Danger(ctx, "An error occurred. Please try again.") 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 { func (h *Auth) RegisterPage(ctx echo.Context) error {
@ -280,7 +286,9 @@ func (h *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 redirect(ctx, routeNameLogin) return redirect.New(ctx).
Route(routeNameLogin).
Go()
default: default:
return fail(err, "unable to create user") return fail(err, "unable to create user")
} }
@ -293,7 +301,9 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error {
"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 redirect(ctx, routeNameLogin) return redirect.New(ctx).
Route(routeNameLogin).
Go()
} }
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.")
@ -301,7 +311,9 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error {
// Send the verification email // Send the verification email
h.sendVerificationEmail(ctx, u) 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) { 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.") 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 { 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) 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 redirect(ctx, routeNameHome) return redirect.New(ctx).
Route(routeNameHome).
Go()
} }
// Check if it matches the authenticated user // 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.") msg.Success(ctx, "Your email has been successfully verified.")
return redirect(ctx, routeNameHome) return redirect.New(ctx).
Route(routeNameHome).
Go()
} }

View File

@ -3,10 +3,8 @@ package handlers
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"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"
) )
@ -31,30 +29,6 @@ func GetHandlers() []Handler {
return handlers 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 // fail is a helper to fail a request by returning a 500 error and logging the error
func fail(err error, log string) error { func fail(err error, log string) error {
// The error handler will handle logging // The error handler will handle logging

View File

@ -3,12 +3,9 @@ package handlers
import ( import (
"errors" "errors"
"net/http" "net/http"
"net/url"
"testing" "testing"
"github.com/labstack/echo/v4" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -23,51 +20,6 @@ func TestGetSetHandlers(t *testing.T) {
assert.Equal(t, h, got[0]) 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) { func TestFail(t *testing.T) {
err := fail(errors.New("err message"), "log message") err := fail(errors.New("err message"), "log message")
require.IsType(t, new(echo.HTTPError), err) require.IsType(t, new(echo.HTTPError), err)

91
pkg/redirect/redirect.go Normal file
View File

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

View File

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