Form Requests
> Self-validating request types with automatic binding, flashing, and redirect-on-failure.
Form requests bundle binding, validation, and error handling into one
call. You define a struct with rules; the handler calls vform.Form[T]
and receives a validated instance, or the request is redirected back
with errors flashed before the handler even continues.
Import path: github.com/velocitykode/velocity/validation/vform
See the validation page for the underlying
rule catalog. Form requests are the sugar on top for HTTP handlers; the
rules themselves use the canonical validation.Rules type, so anything
documented there works inside a Rules() method as-is.
Defining a form request
import "github.com/velocitykode/velocity/validation"
type CreatePostRequest struct {
Title string `json:"title"`
Body string `json:"body"`
}
func (r *CreatePostRequest) Rules() validation.Rules {
return validation.Rules{
"title": {validation.Required(), validation.Min(3)},
"body": {validation.Required(), validation.Min(10)},
}
}Rules are typed values built by the constructors in the validation
package, not strings. Parameters are carried pre-split, so a parameter may
contain any character (including , and |) without escaping.
The Rules() method makes the struct a vform.FormRequest, which is an
alias for router.Validatable: one form struct serves both vform.Form[T]
and ctx.BindValid, so there is a single declaration to satisfy rather
than two identical ones. The return type is validation.Rules
(map[string][]validation.Rule, aliased from contract.ValidationRuleSet)
so the same value can be passed straight into validation.Check or
dbrules.CheckWithDB without an intermediate conversion.
contract.ValidationRuleSet is a defined type, not an alias for a bare
map, so a Rules() method returning a structurally identical map type
fails to satisfy the interface. As a backstop, Validate[T] and Form[T]
detect a method literally named Rules whose signature does not satisfy
FormRequest and return an error naming the offending signature, rather
than silently skipping validation.Custom messages
Implement WithMessages to override per-field rule errors:
func (r *CreatePostRequest) ValidationMessages() validation.Messages {
return validation.Messages{
{Field: "title", Rule: "required"}: "Please provide a title",
{Field: "body", Rule: "min"}: "Body must be at least 10 characters",
}
}Keys are validation.MessageKey values addressing one field+rule pair. The
Rule half is the canonical rule name the constructor emits (required,
min, email, unique, alpha_dash, …), not the Go constructor
identifier.
Authorization
vform.Form[T] itself does not gate requests, place authorization in
middleware (e.g. an auth-required middleware) or check explicitly inside
the handler before calling Form[T]. Authorization that depends on the
bound payload should run after Form[T] returns the validated *T.
Using it in a handler
func (h *PostHandler) Store(ctx *router.Context) error {
req, err := vform.Form[CreatePostRequest](ctx)
if err != nil {
// err is router.ErrValidationAborted on validation failure;
// returning it lets the router skip emitting an error response
// because vform has already redirected back.
return err
}
post := models.NewPost(req.Title, req.Body)
if err := post.Save(); err != nil {
return err
}
return ctx.Redirect(http.StatusSeeOther, "/posts/"+post.ID)
}Failure flow
When validation fails, Form takes over the response:
- Errors are flashed via
ctx.FlashErrors - Original input is flashed as old input via
ctx.FlashInput(sensitive fields are stripped automatically by case-insensitive substring match -password,passwd,passcode,secret,token,pin,cvv,cvc,card,ssn,otp,credential,credentials,api_key,apikey,private_key, andprivatekeyare redacted) - The view engine’s
Backhook is invoked to redirect to the referrer (skipped when no view engine is wired, e.g. an API-only app) Formreturnsrouter.ErrValidationAbortedso the router skips emitting an additional error response
FlashErrors and FlashInput write short-lived encrypted flash cookies
(_velocity_errors and _velocity_old), each sealed under its own AAD
label so one can never be replayed as the other. The view layer reads them
on the next render and injects them as the errors and old props, so
your template can read errors.title and repopulate fields via
old('title'). No handler code after a failed vform.Form call needs
to run, the early return err covers it.
Custom rendering on validation failure
The default Form[T] flow (flash + redirect back) is the right choice
for traditional form posts. For Inertia pages that should re-render with
view-specific props, JSON APIs that want a custom error envelope, or any
case where “redirect back” isn’t a fit, use the lower-level
vform.Validate[T] entry point.
Validate[T] performs the same bind + validate cycle but never flashes
or redirects. It returns the populated *T and a nil *Result on
success, or a zero-value *T and a non-nil *Result carrying the
per-field errors on failure. Only consume *T when *Result is nil.
The error return is reserved for non-validation failures: a bind or
decode error, or a malformed rule set (both wrap a handler bug, never
user input).
import (
"github.com/velocitykode/velocity/validation/vform"
"github.com/velocitykode/velocity/view"
)
func (h *AcceptInvite) Show(ctx *router.Context) error {
req, result, err := vform.Validate[AcceptInviteRequest](ctx)
if err != nil {
// bind error (e.g. malformed JSON), not a validation error
return err
}
if result != nil {
// Validation failed. Render the same view with errors + the
// invitation token still present so the user keeps context.
return view.Render(ctx, "Invite/Accept", view.Props{
"errors": result.All(),
"old": result.Old(),
"invite_id": ctx.Query("token"),
})
}
// Validation passed; req is the bound *AcceptInviteRequest.
return h.acceptAndRedirect(ctx, req)
}view.Render(ctx, component, props...) is the handler-facing entry point
for the Inertia view engine (view.Props is an alias for the engine’s
prop map). It resolves the engine from the context’s service container
and returns an error if no view engine is wired.
Result.All() returns one error per field (map[string]string,
Inertia-friendly), Result.Messages() returns every error
(map[string][]string), Result.Err() collapses the result into an error
wrapping validation.ErrValidationFailed, and Result.Old() returns the
input with sensitive fields removed, ready to flash or pass back as a
view prop.
Structs without Rules
If T does not implement FormRequest, both Form[T] and Validate[T]
just bind the request body into a fresh *T and return it, no
validation runs. This lets the same helpers double as a strict DTO
binder when there’s nothing to check.
Types
// In package validation (all aliased from the stdlib-only contract leaf)
type Rule = contract.ValidationRule // interface{ Rule() ValidationRuleSpec }
type Rules = contract.ValidationRuleSet // map[string][]Rule, keyed by field
type MessageKey = contract.ValidationMessageKey // struct{ Field, Rule string }
type Messages = contract.ValidationMessages // map[MessageKey]string
// In package validation/vform
type FormRequest = router.Validatable // interface{ Rules() validation.Rules }
type WithMessages interface {
ValidationMessages() validation.Messages
}
type Result = validation.Result // re-exported for Validate[T] callers
func Form[T any](ctx *router.Context) (*T, error)
func Validate[T any](ctx *router.Context) (*T, *Result, error)Relation to the validation package
vform is the HTTP-handler entry point: it owns binding, flashing, and
redirect-back. The lower-level validation.Check, validation.CheckW, and
validation.CheckData functions are the canonical entry points outside
HTTP, or inside HTTP when you want to control the response shape yourself
without Validate[T]’s bind-then-error flow. All of them return
(*Result, error), keeping a malformed rule set (a handler bug) distinct
from field-level failures (user input).
The DB-backed rules (Unique, Exists) execute in the
validation/dbrules subpackage so the core validation package pulls in
no orm or SQL-driver dependency: use dbrules.CheckWithDB /
CheckWithDBW and dbrules.CheckDataWithDB / CheckDataWithDBCtx when
your rule set names them. vform already routes through
dbrules.CheckWithDBW and resolves the database from the context’s
service container, so a Rules() method naming Unique works without any
extra wiring. When no database is reachable, a Unique or Exists rule is
reported as a configuration error rather than silently failing the field.
Related
- Validation - the underlying rule engine
vformdelegates to - Handlers - where
Form[T]andValidate[T]plug into request flow - Frontend Forms - client-side form helpers that pair with flashed errors and old input