Muzak logomuzak
v0.2.00
You are reading the 0.1.1 documentation. The current release is 0.2.0. Read this page for it →

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 bindsAccepted media types
form fields onlyapplication/x-www-form-urlencoded and multipart/form-data
any file fieldmultipart/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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.