Documentation / Forms and validation

Forms and validation

Bind a request into a struct, check it with tags or a schema, and answer a failed submit with a fragment that keeps what the visitor typed. Covers CSRF, uploads, and multi-step forms.

Bind, validate, answer

hyperchi.Bind[T](c) decodes the request into a new T and checks it in one call. JSON bodies decode by json tags. Forms, multipart forms, and query strings decode by form tags, falling back to json and then the lower-cased field name. A pointer field such as *int stays nil when the field is missing or empty, so required and min can tell "not sent" from zero.

The returned error already carries an HTTP status, so a handler can return it as is:

The rules are required, min=N, max=N (length for strings and slices, value for numbers), email, url, and oneof=a b c. An unknown rule in a tag is a 500 on the first request, so a typo fails loudly instead of validating nothing.

htmx 4 swaps every status except 204 and 304, so a 422 response lands in the page like any other. The error pipeline decides what the 422 looks like: JSON for an API client, a small alert for htmx, an error page for a browser (see Errors). For a form you usually want better than that: the form again, with the visitor's input and a message beside each bad field.

A form that explains itself

hyperchi.FieldErrors(err) returns the per-field messages of a validation failure, or nil for any other error. Use it to re-render the form fragment with status 422:

type Signup struct {
    Name  string `form:"name" validate:"required,max=80"`
    Email string `form:"email" validate:"required,email"`
    Plan  string `form:"plan" validate:"oneof=free team"`
    Age   *int   `form:"age" validate:"min=13"`
}

app.Post("/signup", func(c *hyperchi.Context) error {
    in, err := hyperchi.Bind[Signup](c)
    if fields := hyperchi.FieldErrors(err); fields != nil {
        return c.Status(422).Fragment("signup-form", hyperchi.H{
            "in":     in,
            "errors": fields,
        })
    }
    if err != nil {
        return err // a 400 or 413 for an unreadable body
    }
    return c.Fragment("signup-done", in)
})

Bind returns the partly filled struct along with the error, so in holds whatever decoded cleanly. The GET route that first shows the form passes "errors": map[string][]string{} so the template can index it the same way. Messages read as the end of a sentence ("is required", "must be at least 13"), so the template supplies the subject:

{{define "signup-form"}}
<form hx-post="/signup" hx-target="this" hx-swap="outerHTML">
  <label>Name
    <input name="name" value="{{.in.Name}}"{{if index .errors "name"}} aria-invalid="true"{{end}}>
  </label>
  {{with index .errors "name"}}<p class="field-error">Name {{index . 0}}.</p>{{end}}

  <label>Email
    <input name="email" type="email" value="{{.in.Email}}">
  </label>
  {{with index .errors "email"}}<p class="field-error">Email {{index . 0}}.</p>{{end}}

  <button>Join</button>
</form>
{{end}}

html/template escapes .in.Name, so echoing input back is safe. Never echo a password field.

A cross-field rule goes in Validate:

type Booking struct {
    Start time.Time `form:"start" validate:"required"`
    End   time.Time `form:"end" validate:"required"`
}

func (b *Booking) Validate() error {
    if !b.End.After(b.Start) {
        return hyperchi.Unprocessable("Please correct the highlighted fields.",
            map[string][]string{"end": {"must be after the start"}})
    }
    return nil
}

time.Time fields accept 2006-01-02, 2006-01-02T15:04, and RFC 3339.

Schemas for input with no struct

When the shape is only known at runtime, build a validator.Schema, register it on app.Schemas, and call c.ValidateSchema(name). It reads a JSON or form body and returns the cleaned values, or the same 422 that Bind produces:

app.Schemas.Register("contact", validator.NewSchema().
    Field("email", validator.String().Email().Required()).
    Field("topic", validator.String().OneOf("sales", "support").Required()).
    Field("message", validator.String().MinLength(10).MaxLength(2000)))

app.Post("/contact", func(c *hyperchi.Context) error {
    data, err := c.ValidateSchema("contact")
    if err != nil {
        return err
    }
    return c.JSON(hyperchi.H{"received": data["topic"]})
})

An unregistered name is a programming error and answers 500. FieldErrors works on this error too.

CSRF

app.AutoMiddleware() turns CSRF protection on, because Config.Security.CSRF is true by default. An app that assembles its own stack adds it with app.Use(app.Middleware.CSRF()); installing it twice is harmless. A GET request gets a csrf_token cookie (SameSite Strict, Secure outside development, readable by scripts), and every other method must send the token back or get a 403 through the error pipeline.

Every template rendered for a request gets the token as .csrfToken. It is masked with a fresh one-time pad on every render, so no two responses contain the same bytes. That keeps a compressed page from leaking the token through the BREACH attack, and it is why production turns gzip on safely. In Go, c.CSRFToken() returns the same kind of masked token.

app := hyperchi.New()
app.AutoMiddleware() // CSRF included

app.Get("/signup", func(c *hyperchi.Context) error {
    return c.View("signup", hyperchi.H{"errors": map[string][]string{}})
})

Give htmx the token once, on the body in your layout, and every request on the page carries it. htmx 4 does not inherit attributes unless you ask, hence :inherited. Projects made with hyperchi new already have this line:

<body{{with .csrfToken}} hx-headers:inherited='{"X-CSRF-Token": "{{.}}"}'{{end}}>

A plain form that does not go through htmx sends the same token in a hidden field named csrf_token: <input type="hidden" name="csrf_token" value="{{.csrfToken}}">. The field accepts masked tokens only. The header also accepts the raw cookie value, for scripts that read the cookie themselves.

A page pre-rendered once at startup (app.PreRender, app.PreRenderedPage) has no request, so it cannot carry a token. Protect the forms on such pages with app.ProtectForm, which refuses cross-site posts by their Origin and Sec-Fetch-Site headers, and set cfg.Security.CSRF = false. Do not turn the token check off for any other reason.

File uploads

A *multipart.FileHeader field (or a slice of them) in a Bind struct receives the uploaded file. Bind caps the whole multipart body at 32 MiB. security.SaveUpload stores the file under a directory you choose:

type Avatar struct {
    Caption string                `form:"caption" validate:"max=120"`
    File    *multipart.FileHeader `form:"avatar"`
}

app.Post("/avatar", func(c *hyperchi.Context) error {
    in, err := hyperchi.Bind[Avatar](c)
    if err != nil {
        return err
    }
    if in.File == nil {
        return hyperchi.Unprocessable("Please correct the highlighted fields.",
            map[string][]string{"avatar": {"is required"}})
    }
    saved, err := security.SaveUpload("data/avatars", in.File, security.SaveOptions{MaxSize: 2 << 20})
    var limit *security.UploadLimitError
    if errors.As(err, &limit) {
        return hyperchi.NewHTTPError(http.StatusRequestEntityTooLarge, "That file is too large.").Wrap(err)
    }
    if err != nil {
        return hyperchi.BadRequest("That file type is not accepted.").Wrap(err)
    }
    return c.Fragment("avatar-saved", hyperchi.H{"name": saved.Name, "original": saved.OriginalName})
})

SaveUpload never lets the client choose the path. It names the file with 128 random bits plus an extension taken from the type it sniffs from the first 512 bytes, writes through an os.Root so a symlink cannot escape the directory, and removes a partial file on failure. By default it accepts JPEG, PNG, GIF, WebP, plain text, and PDF; pass SaveOptions.Extensions (start from security.DefaultSaveExtensions()) to change that. HTML, XML, SVG, and JavaScript are refused even if you list them. SavedFile.OriginalName is for display only.

To check type, count, and size limits before your handler runs, put security.UploadMiddleware on the route and let SaveAll store the files that passed. It answers 413 for a size failure and 400 for the rest. The default config requires a signed-in user, so set your own limits:

limits := &security.FileUploadConfig{
    MaxFileSize:  5 << 20,
    MaxFiles:     3,
    AllowedTypes: []string{"image/png", "image/jpeg"},
}
app.Post("/gallery", func(c *hyperchi.Context) error {
    result := security.GetValidationResult(c.Request())
    saved, err := result.SaveAll("data/gallery", security.SaveOptions{})
    if err != nil {
        return err
    }
    return c.JSON(saved)
}, security.UploadMiddleware(limits))

SaveAll stores every file or none. The whole request body is capped at MaxFiles * MaxFileSize plus 1 MiB for boundaries. Serve stored files with a Content-Type from their extension and X-Content-Type-Options: nosniff.

Multi-step forms

patterns.MultiStepForm serves a wizard at one path and keeps the answers in the visitor's session, so it needs sessions first:

if err := app.UseKVStoreMemoryAdvanced("sessions"); err != nil {
    return err
}
if err := app.UseSessions(hyperchi.KVSessionConfig{Backend: "sessions", MaxAge: 30 * time.Minute}); err != nil {
    return err
}
app.Use(app.SessionMiddleware())

account := validator.NewSchema().
    Field("email", validator.String().Email().Required())

pm := app.Extensions().GetPatternManager()
pm.MultiStepForm("/join", patterns.MultiStepFormConfig{
    SuccessURL: "/joined",
    Steps: []patterns.FormStep{
        {Name: "account", Title: "Your account", Template: "join-account",
            Fields: []string{"email"}, Validate: patterns.SchemaStep(account)},
        {Name: "plan", Title: "Pick a plan", Template: "join-plan",
            Fields: []string{"plan"},
            Validate: func(v url.Values) map[string][]string {
                if v.Get("plan") != "free" && v.Get("plan") != "team" {
                    return map[string][]string{"plan": {"must be free or team"}}
                }
                return nil
            }},
    },
    Complete: func(r *http.Request, answers url.Values) error {
        return createAccount(r, answers.Get("email"), answers.Get("plan"))
    },
})

Each step template receives .step, .total_steps, .progress, .step_info (the FormStep), .values (url.Values), and .errors:

{{define "join-account"}}
<form hx-post="/join" hx-target="this" hx-swap="outerHTML">
  <input type="hidden" name="step" value="{{.step}}">
  <label>Email <input name="email" type="email" value="{{.values.Get "email"}}"></label>
  {{range index .errors "email"}}<p class="field-error">{{.}}</p>{{end}}
  <button>Continue</button>
</form>
{{end}}

Without session middleware on the wizard's route, each request is a 500 whose logged cause is patterns.ErrNoWizardStore. The waitlist on the home page is a running three-step wizard.

Keep going

A form is the front of a write path, so the data layer is next. For what happens when a handler returns the error Bind gave it, read Errors.