Recipes

Input validation

The pkg/validate package provides field-level input validation that returns structured 422 responses. Validation errors are shown inline on forms in the admin panel and user frontend.

import "github.com/stanza-go/framework/pkg/validate"

Basic usage

Validate inside a handler after parsing the request body:

func createHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        var req createRequest
        if err := http.ReadJSON(r, &req); err != nil {
            http.WriteError(w, http.StatusBadRequest, "invalid request body")
            return
        }

        v := validate.Fields(
            validate.Required("name", req.Name),
            validate.Required("email", req.Email),
            validate.Email("email", req.Email),
            validate.Required("password", req.Password),
            validate.MinLen("password", req.Password, 8),
        )
        if v.HasErrors() {
            v.WriteError(w)
            return
        }

        // proceed with valid data
    }
}

WriteError sends a 422 response:

{
  "error": "validation failed",
  "fields": {
    "name": "is required",
    "email": "must be a valid email address",
    "password": "must be at least 8 characters"
  }
}

Available validators

ValidatorCheck
Required(field, value)Non-empty after trimming whitespace
MinLen(field, value, min)At least min characters (skips empty)
MaxLen(field, value, max)At most max characters
Email(field, value)Basic email format check (skips empty)
OneOf(field, value, ...allowed)Value is one of the allowed strings (skips empty)
Positive(field, value)Integer greater than zero
InRange(field, value, min, max)Integer within [min, max] inclusive
Check(field, ok, message)Custom check — if ok is false, returns the message

Ordering matters

Only the first error per field is kept. Put Required before format validators for the same field:

v := validate.Fields(
    validate.Required("email", req.Email),    // checked first
    validate.Email("email", req.Email),       // skipped if already has error
)

If email is empty, the user sees "is required" — not "must be a valid email address".


Custom validation with Check

Use Check for logic that doesn't fit the built-in validators:

v := validate.Fields(
    validate.Required("start_date", req.StartDate),
    validate.Required("end_date", req.EndDate),
    validate.Check("end_date", req.EndDate > req.StartDate, "must be after start date"),
    validate.Check("quantity", req.Quantity <= stock, "exceeds available stock"),
)

Status codes

CodeMeaning
400 Bad RequestMalformed request — can't parse JSON, invalid path param
422 Unprocessable EntityValid JSON, but field values are invalid

Use http.WriteError(w, http.StatusBadRequest, msg) for parse errors. Use v.WriteError(w) for validation errors.


Frontend integration

The admin panel and user frontend display validation errors inline. When the API returns a response with a fields object, each field's error is shown below its input.

Example React pattern:

const [errors, setErrors] = useState<Record<string, string>>({});

async function onSubmit(data: FormData) {
    const res = await fetch("/api/admin/products", {
        method: "POST",
        body: JSON.stringify(data),
    });
    if (!res.ok) {
        const body = await res.json();
        if (body.fields) {
            setErrors(body.fields);
            return;
        }
    }
}

// In the form:
<input name="name" />
{errors.name && <p className="text-sm text-red-600">{errors.name}</p>}

Tips

  • Validate at the boundary. Only validate user input in HTTP handlers. Internal code and framework calls don't need validation.
  • One validator call per handler. Collect all checks into a single validate.Fields() call for a clean, scannable validation block.
  • Empty strings pass format validators. Email, MinLen, OneOf all skip empty values. Use Required to enforce presence.
  • Don't validate what the database enforces. Unique constraints, foreign keys, and NOT NULL are handled by SQLite — catch those as errors in the db.Exec response, not in validation.
Previous
Database transactions