So far, there's been little that visitors to your web app can actually do with it (except admire its beauty). Time to change that.
We will add a newsletter signup to the front page. You'll end up with:
Because there's a lot of different components that we need to write, I will be splitting this up over a couple of sections. We will start by changing the front page and adding the HTTP handler. Let's get going. ๐
The code from this section can also be checked out with:
$ git fetch && git checkout --track golangdk/newsletter-signup
Open up views/front.go
and add the form, so the front page component looks like this:
views/front.gopackage views import ( g "github.com/maragudk/gomponents" "github.com/maragudk/gomponents-heroicons/solid" . "github.com/maragudk/gomponents/html" ) func FrontPage() g.Node { return Page( "Canvas", "/", H1(g.Text(`Solutions to problems.`)), P(g.Raw(`Do you have problems? We also had problems.`)), P(g.Raw(`Then we created the <em>canvas</em> app, and now we don't! ๐ฌ`)), H2(g.Text(`Do you want to know more?`)), P(g.Text(`Sign up to our newsletter below.`)), FormEl(Action("/newsletter/signup"), Method("post"), Class("flex items-center max-w-md"), Label(For("email"), Class("sr-only"), g.Text("Email")), Div(Class("relative rounded-md shadow-sm flex-grow"), Div(Class("absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"), solid.Mail(Class("h-5 w-5 text-gray-400")), ), Input(Type("email"), Name("email"), ID("email"), AutoComplete("email"), Required(), Placeholder("me@example.com"), TabIndex("1"), Class("focus:ring-gray-500 focus:border-gray-500 block w-full pl-10 text-sm border-gray-300 rounded-md")), ), Button(Type("submit"), g.Text("Sign up"), Class("ml-3 inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 flex-none")), ), ) }
If you ignore all the CSS classes that style the form, there's only a few things left. Let's try and remove the styling and other non-functional elements just so it's easier to read, and this is what's left:
FormEl(Action("/newsletter/signup"), Method("post")
Input(Type("email"), Name("email"), AutoComplete("email"), Required()),
Button(Type("submit"), g.Text("Sign up")),
)
As you can see, it's a form that makes an HTTP POST request to /newsletter/signup
. There's a single required input field of both name and type email
. There's also a hint to the browser that this can be auto-completed with the visitor's email address. That's it.
Small note: the form element is called FormEl
in gomponents because there's also a form
attribute in HTML, unsurprisingly called FormAttr
in gomponents.
Your front page should now look something like this:
Let's move on to the handler that receives the form data.
The HTTP handler that will receive the form data will end up doing this:
Let's start with adding an Email
type. I tend to create a model
package to keep all my models in, so create a new file model/email.go
and add this:
model/email.gopackage model import ( "regexp" ) // emailAddressMatcher for valid email addresses. // See https://regex101.com/r/1BEPJo/latest for an interactive breakdown of the regexp. // See https://html.spec.whatwg.org/#valid-e-mail-address for the definition. var emailAddressMatcher = regexp.MustCompile( // Start of string `^` + // Local part of the address. Note that \x60 is a backtick (`) character. `(?P<local>[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+)` + `@` + // Domain of the address `(?P<domain>[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)` + // End of string `$`, ) type Email string func (e Email) IsValid() bool { return emailAddressMatcher.MatchString(string(e)) } func (e Email) String() string { return string(e) }
Email
is basically just a string with an IsValid
method on it, that validates the email address according to the emailAddressMatcher
regular expression. I won't go into details with how exactly it validates the email. If you're interested, play around with the regular expression on regex101.com.
To make sure that something as important as email address validation works correctly, let's add a test in model/email_test.go
:
model/email_test.gopackage model_test import ( "testing" "github.com/matryer/is" "canvas/model" ) func TestEmail_IsValid(t *testing.T) { tests := []struct { address string valid bool }{ {"me@example.com", true}, {"@example.com", false}, {"me@", false}, {"@", false}, {"", false}, } t.Run("reports valid email addresses", func(t *testing.T) { for _, test := range tests { t.Run(test.address, func(t *testing.T) { is := is.New(t) e := model.Email(test.address) is.Equal(test.valid, e.IsValid()) }) } }) }
For bonus points, add a couple of extra items in the tests
slice, and be surprised by how many strings are actually valid email addresses. ๐
The handler NewsletterSignup
takes our mux
as well as something that implements the signupper
interface, in handlers/newsletter.go
:
handlers/newsletter.gopackage handlers import ( "context" "net/http" "github.com/go-chi/chi/v5" "canvas/model" "canvas/views" ) type signupper interface { SignupForNewsletter(ctx context.Context, email model.Email) (string, error) } func NewsletterSignup(mux chi.Router, s signupper) { mux.Post("/newsletter/signup", func(w http.ResponseWriter, r *http.Request) { email := model.Email(r.FormValue("email")) if !email.IsValid() { http.Error(w, "email is invalid", http.StatusBadRequest) return } if _, err := s.SignupForNewsletter(r.Context(), email); err != nil { http.Error(w, "error signing up, refresh to try again", http.StatusBadGateway) return } http.Redirect(w, r, "/newsletter/thanks", http.StatusFound) }) } func NewsletterThanks(mux chi.Router) { mux.Get("/newsletter/thanks", func(w http.ResponseWriter, r *http.Request) { _ = views.NewsletterThanksPage("/newsletter/thanks").Render(w) }) }
What's the signupper
interface? It's a small interface with just one method, SignupForNewsletter
, and it enables decoupling our HTTP handler from the database implementation that we will write later. This makes it both extremely easy to figure out what this handler depends on, and makes it really easy to test as well. The handler doesn't know about any database methods we will add later, just this one. Perfect.
r.FormValue
parses the request form data if it isn't parsed already, and returns the field identified by the name parameter. We pass its return value directly to our Email
model, so we can validate it right after.
Because a lot of the heavy lifting of validation is done by the browser (that's what the required
and type="email"
attributes on the input
element in the form are for), we will just return a very raw error page for now.
After that, we call SignupForNewsletter
and check for errors. We ignore the string return value for now (we'll get back to what it is later) and will also just send a raw error page. The error pages will be improved in a later section.
Finally we redirect to /newsletter/thanks
using a HTTP 302 Found
status code. This is a common pattern after a POST request. If we rendered a view directly, a visitor refreshing the page would resend the form. With the redirect, that doesn't happen.
The NewsletterThanks
just registers the route and displays a thank you page, which you should define in views/newsletter.go
:
views/newsletter.gopackage views import ( g "github.com/maragudk/gomponents" . "github.com/maragudk/gomponents/html" ) func NewsletterThanksPage(path string) g.Node { return Page( "Thanks for signing up!", path, H1(g.Text(`Thanks for signing up!`)), P(g.Raw(`Now check your inbox (or spam folder) for a confirmation link. ๐`)), ) }
I consider the signup handler important enough to test, so we will test both that it accepts and saves valid email addresses, and rejects invalid ones.
The way we test it is very similar to how we tested our health handler. The new part is that we make a POST request instead of a GET request (and use a new test helper makePostRequest
), and also that we use a mock that satisfies the signupper
interface from before. Add this to handlers/newsletter_test.go
:
handlers/newsletter_test.gopackage handlers_test import ( "context" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/matryer/is" "canvas/handlers" "canvas/model" ) type signupperMock struct { email model.Email } func (s *signupperMock) SignupForNewsletter(ctx context.Context, email model.Email) (string, error) { s.email = email return "", nil } func TestNewsletterSignup(t *testing.T) { mux := chi.NewMux() s := &signupperMock{} handlers.NewsletterSignup(mux, s) t.Run("signs up a valid email address", func(t *testing.T) { is := is.New(t) code, _, _ := makePostRequest(mux, "/newsletter/signup", createFormHeader(), strings.NewReader("email=me%40example.com")) is.Equal(http.StatusFound, code) is.Equal(model.Email("me@example.com"), s.email) }) t.Run("rejects an invalid email address", func(t *testing.T) { is := is.New(t) code, _, _ := makePostRequest(mux, "/newsletter/signup", createFormHeader(), strings.NewReader("email=notanemail")) is.Equal(http.StatusBadRequest, code) }) } // makePostRequest and returns the status code, response header, and the body. func makePostRequest(handler http.Handler, target string, header http.Header, body io.Reader) (int, http.Header, string) { req := httptest.NewRequest(http.MethodPost, target, body) req.Header = header res := httptest.NewRecorder() handler.ServeHTTP(res, req) result := res.Result() bodyBytes, err := io.ReadAll(result.Body) if err != nil { panic(err) } return result.StatusCode, result.Header, string(bodyBytes) } func createFormHeader() http.Header { header := http.Header{} header.Set("Content-Type", "application/x-www-form-urlencoded") return header }
The important part is that we check the response status code, and that the signupperMock
got the correctly parsed email address.
The last part is adding the two new handlers to our server/routes.go
file. Because we don't have the database part implemented yet, we just pass a temporary mock to the signup handler:
server/routes.gopackage server import ( "context" "canvas/handlers" "canvas/model" ) func (s *Server) setupRoutes() { handlers.Health(s.mux) handlers.FrontPage(s.mux) handlers.NewsletterSignup(s.mux, &signupperMock{}) handlers.NewsletterThanks(s.mux) } type signupperMock struct{} func (s signupperMock) SignupForNewsletter(ctx context.Context, email model.Email) (string, error) { return "", nil }
Finally, make sure to run your tests and check that everything works as expected. Also, start the server, open up your browser at localhost:8080 and play around with the signup form. See what happens when you don't enter an email address, when it's invalid, and when it's valid.
Then stretch a little, go eat a nice snack, and come back for the next section: implementing the database functions for newsletter signup.
Get help at support@golang.dk.