Framework

Validation

The pkg/validate package provides field-level input validation that returns structured JSON error responses. It is designed for HTTP handler input — validate at the system boundary, not inside internal code.

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

How it works

Create a Validator by passing a list of field checks to Fields. Each check returns nil on success or a *FieldError on failure. Only the first error per field is kept.

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

WriteError sends a 422 Unprocessable Entity response:

{
  "error": "validation failed",
  "fields": {
    "password": "must be at least 8 characters"
  }
}

Validators

Required

Checks that a string is non-empty after trimming whitespace.

validate.Required("name", req.Name)
// → "is required"

MinLen / MaxLen

Checks string length bounds. Both skip empty strings — use Required to enforce presence.

validate.MinLen("password", req.Password, 8)
// → "must be at least 8 characters"

validate.MaxLen("bio", req.Bio, 500)
// → "must be at most 500 characters"

Email

Basic structural email check — local part, @, domain with a dot. Skips empty strings.

validate.Email("email", req.Email)
// → "must be a valid email address"

URL

Checks that a string is a valid HTTP or HTTPS URL — correct scheme, parseable, and has a host. Skips empty strings.

validate.URL("callback_url", req.CallbackURL)
// → "must be a valid URL"

OneOf

Checks that a string is one of the allowed values. Skips empty strings.

validate.OneOf("role", req.Role, "admin", "viewer", "editor")
// → "must be one of: admin, viewer, editor"

Positive

Checks that an integer is greater than zero.

validate.Positive("quantity", req.Quantity)
// → "must be a positive number"

InRange

Checks that an integer is within [min, max] inclusive.

validate.InRange("age", req.Age, 18, 120)
// → "must be between 18 and 120"

Check

Generic validator for custom logic. If ok is false, the message is returned.

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

Validator reference

FunctionSignatureSkips emptyMessage
Required(field, value string)Nois required
MinLen(field, value string, min int)Yesmust be at least N characters
MaxLen(field, value string, max int)Nomust be at most N characters
Email(field, value string)Yesmust be a valid email address
URL(field, value string)Yesmust be a valid URL
OneOf(field, value string, ...allowed)Yesmust be one of: a, b, c
Positive(field string, value int)must be a positive number
InRange(field string, value, min, max int)must be between N and M
Check(field string, ok bool, message string)Custom message

Ordering

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

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".


Validator type

The Validator returned by Fields exposes three methods:

v := validate.Fields(...)

v.HasErrors() bool              // true if any check failed
v.Errors() map[string]string    // field → message map (read-only)
v.WriteError(w)                 // write 422 JSON response

The FieldError type is public for advanced use cases:

type FieldError struct {
    Field   string
    Message string
}

Each validator function returns *FieldError (nil on success). You can use this to build conditional validation:

var checks []*validate.FieldError
checks = append(checks, validate.Required("name", req.Name))
if req.Type == "email" {
    checks = append(checks, validate.Required("email", req.Email))
    checks = append(checks, validate.Email("email", req.Email))
}
v := validate.Fields(checks...)

Error response format

WriteError always produces the same structure:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{"error":"validation failed","fields":{"field":"message"}}

Use 400 Bad Request (via http.WriteError) for malformed input that can't be parsed. Use 422 (via v.WriteError) for structurally valid input with invalid field values.


Tips

  • Validate at the boundary. Only use validate in HTTP handlers. Internal functions should trust their callers.
  • One call per handler. Collect all checks into a single validate.Fields() call.
  • Empty strings pass format validators. Email, MinLen, OneOf skip empty values. Use Required to enforce presence.
  • Don't duplicate database constraints. Unique, foreign key, and NOT NULL violations are database errors — handle them as 409 or 500, not 422.

See the Input validation recipe for practical handler examples and frontend integration patterns.

Previous
Authentication
Next
Email