Forms and HTML
A field tagged form:"name" is bound from a form body, converted by the same setters that
convert a query parameter. A route that binds form values and no files at all is not a
special kind of route: it validates, documents and fails exactly like any other.
Binding a form
// LoginIn is the sign-in form.
//
// The two fields carry no location tag beyond `form`, so this route reads a
// form body rather than JSON and accepts both encodings a browser can produce:
// application/x-www-form-urlencoded, which a plain HTML form posts, and
// multipart/form-data, which one with an enctype does.
type LoginIn struct {
Username string `form:"username" doc:"The account to sign in to"`
Password string `form:"password" doc:"The account's password"`
// Next is where to send the browser afterwards. A form value is required
// like the body is, so an optional one says so.
Next string `form:"next" required:"false" doc:"Where to redirect after signing in"`
}
A form value is body content, so it is required by default, unlike a query parameter.
Mark an optional one required:"false" or give it a default.
type SearchForm struct {
Query string `form:"q"`
Page int `form:"page" default:"1"`
Safe bool `form:"safe" default:"true"`
}
What a form route accepts
| The input binds | Accepted media types |
|---|---|
form fields only | application/x-www-form-urlencoded and multipart/form-data |
any file field | multipart/form-data only, because urlencoded cannot carry a file |
That is why a plain HTML form with no enctype posts to a form route without being told
to.
An input that binds form or file fields cannot also declare JSON members. A field with
no location tag on such an input is a build error telling you to tag it with form or move
it to the path, query, header or cookie.
Validating a form
// Validate bounds the credentials before any comparison is attempted.
//
// The rules are deliberately shape-only. A password that is too short is worth
// rejecting outright, but nothing here may hint at whether the account exists;
// that answer belongs to the handler, which gives the same one either way.
func (in *LoginIn) Validate(v *muzak.Validation) {
v.String(&in.Username).Trim().Lower().MinLen(2).MaxLen(32)
v.String(&in.Password).MinLen(8).MaxLen(128)
}
Failures are reported with "location": "body", because a form value is body content.
The handler
// Login establishes a session from a submitted form.
//
// By the time this runs the form has been read, the username trimmed and
// lower-cased and both lengths checked, so the handler is left with the one
// decision that is actually its own.
func Login(ctx *muzak.Context, in schemas.LoginIn) (schemas.LoginOut, error) {
expected, known := accounts[in.Username]
// The comparison runs even for an unknown account, and the same answer is
// given either way. Returning "no such user" would turn this endpoint into
// a way to enumerate accounts, and returning early would let its timing do
// the same thing more quietly.
matches := subtle.ConstantTimeCompare([]byte(expected), []byte(in.Password)) == 1
if !known || !matches {
return schemas.LoginOut{}, muzak.Unauthorized("the username or password is incorrect")
}
session := uuid.NewV4().String()
ctx.SetCookie(&http.Cookie{
Name: "session_id",
Value: session,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: false,
MaxAge: 3600,
})
return schemas.LoginOut{
Username: in.Username,
SessionID: session,
Next: in.Next,
}, nil
}
The rate limit on a sign-in route is not optional in practice. See Rate Limiting for why the count belongs before the guards.
Returning HTML
A handler returning muzak.HTML bypasses JSON encoding entirely: the string is written
verbatim under text/html, and the generated document describes the response as
text/html rather than as a JSON schema.
// LoginForm serves the page that posts to Login.
//
// It is a plain form with no enctype, so the browser posts it as
// application/x-www-form-urlencoded. The route accepts that without being told
// to, because it binds form values and no files.
func LoginForm(ctx *muzak.Context, _ muzak.Empty) (muzak.HTML, error) {
return muzak.HTML(`<body>
<form action="/login/" method="post">
<label>Username <input name="username" autocomplete="username"></label>
<label>Password <input name="password" type="password" autocomplete="current-password"></label>
<input type="hidden" name="next" value="/feed">
<input type="submit" value="Sign in">
</form>
</body>`), nil
}
Everything else about the route is unchanged: status, headers, cookies and errors work exactly as they do for a JSON route.
Escaping
The value is written as given. Muzak does not escape it, because it cannot tell markup the handler meant from text it did not. Anything a client supplied has to be escaped by the handler.
func Greeting(ctx *muzak.Context, in GreetIn) (muzak.HTML, error) {
return muzak.HTML("<p>Hello, " + html.EscapeString(in.Name) + "</p>"), nil
}
For anything larger than a fragment, use html/template, which escapes by construction:
var page = template.Must(template.ParseFiles("templates/profile.html"))
func Profile(ctx *muzak.Context, in ProfileIn) (muzak.HTML, error) {
var buf strings.Builder
if err := page.Execute(&buf, in); err != nil {
return "", err
}
return muzak.HTML(buf.String()), nil
}
Parse the templates once and publish the set with muzak.WithSingleton or
muzak.Singleton rather than parsing per request. See
Dependencies.
Redirecting after a post
func Login(ctx *muzak.Context, in schemas.LoginIn) (muzak.Empty, error) {
// ... establish the session ...
ctx.SetHeader("Location", in.Next)
ctx.SetStatus(http.StatusSeeOther)
return muzak.Empty{}, nil
}
Next came from the client, so treat it as input: validate it against a set of known
destinations, or require it to be a relative path, before putting it in a Location
header.
Body limits
A route that binds form or file fields is bounded by MaxUploadSize rather than
MaxBodySize, defaulting to 32 mebibytes. Declare it once on the router that holds those
routes:
r := muzak.NewRouter(
muzak.WithTags("uploads"),
muzak.MaxUploadSize(32<<20),
muzak.MaxFileSize(10<<20),
)
Testing a form route
func TestLogin(t *testing.T) {
client := testclient.New(t, buildApp())
form := url.Values{"username": {"muzak"}, "password": {"correct-horse-battery"}}
res := client.Post("/login/", testclient.Body(
"application/x-www-form-urlencoded", strings.NewReader(form.Encode())))
res.AssertStatus(http.StatusOK)
if len(res.Cookies) == 0 {
t.Error("want a session cookie")
}
}
Where to go next
File Uploads covers the other half of a multipart body, and Cookies covers the session a sign-in establishes.