httperr

Validation

Struct-tag-based request validation with go-playground/validator, run as strict middleware, with 422 problem responses.

Request validation in gpsystem is declarative: you write rules as tags on struct fields, and httperr/validate handles execution and error rendering. The engine is go-playground/validator v10 (Go's de facto standard validator); the validate package wires it so that every failure becomes an RFC 9457 problem response with a per-field error list, with no handler code involved.

import "github.com/gp-system/httperr/validate"

The Validator type

func New(opts ...Option) *Validator

func (v *Validator) Validate(out any) error // validates out, wraps failures as a *httperr.Problem (422)
func (v *Validator) Struct(s any) error     // the same, named for use outside a binding pipeline

func Translate(verrs validator.ValidationErrors) []httperr.FieldError

New builds the validator with two fixed defaults:

  • WithRequiredStructEnabled(): required semantics for nested structs follow the validator v11 default;
  • json-tag field names: field names in errors come from the json tag (customerEmail, not CustomerEmail), with dotted paths for nested structs (address.city).

On failure, Validate/Struct does not return a raw validator error but a ready *httperr.Problem (422), with the field errors translated into httperr.FieldErrors via Translate: the error renderer has nothing left to do with it. Translate writes human-readable messages for the common rules (required, email, min, max, oneof, gt, ...); unknown rules fall back to the failed rule <tag>=<param> form.

Wiring: strict middleware before the handler

net/http's standard strict server (generated by oapi-codegen from the OpenAPI spec, see Codegen pipeline) decodes the request with json.Decoder, without validating it: validate closes that gap as strict middleware, validating the fully bound request object (parameters + body) before the handler runs. In a generated gpsystem project, the kit's server.StrictValidator is the thin adapter that wires a *validate.Validator into that middleware slot; the generated register.go wires it automatically:

shopapigen.NewStrictHandlerWithOptions(apiHandler,
    []shopapigen.StrictMiddlewareFunc{
        server.StrictValidator[shopapigen.StrictHandlerFunc](nil),
        // ...
    },
    shopapigen.StrictHTTPServerOptions{ /* httperr hooks */ })

The nil argument is deliberate: server.StrictValidator(nil) resolves the validator at request time, first looking for the one set via server.WithValidator in the context, and only then falling back to validate.New(). A single option in main.go thus applies to the whole application, with no edits to generated code. Details, including the exact middleware ordering relative to policy enforcement: the server core.

Outside a generated project, wiring validate.New() needs nothing kit-specific: call v.Struct(req) at whatever point in your own net/http handler or middleware chain you'd otherwise validate a decoded request, and return the resulting error unchanged; it's already a *httperr.Problem.

The shop: the PlaceOrder request

The order-placement body carries three rules:

type placeOrderBody struct {
    ProductID     string `json:"productId" validate:"required,uuid4"`
    Quantity      int    `json:"quantity" validate:"required,gt=0"`
    CustomerEmail string `json:"customerEmail" validate:"required,email"`
}

A request violating all three (quantity: 0, a malformed email, a missing productId) gets this response:

{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "One or more fields failed validation.",
  "instance": "/api/v1/shop/orders",
  "errors": [
    { "field": "productId", "rule": "required", "message": "is required" },
    { "field": "quantity", "rule": "gt", "message": "must be greater than 0" },
    { "field": "customerEmail", "rule": "email", "message": "must be a valid email address" }
  ]
}

The rule is machine-readable (the frontend can branch on it per field), the message is a human-readable default: the final, localized text is typically mapped by the client from the field + rule pair.

On your own hand-written structs (service input, internal DTOs) you write the tags yourself. For spec-first generated request models, the tag source is the OpenAPI x-oapi-codegen-extra-tags extension, which you attach to a field from TypeSpec with the @extension decorator:
model PlaceOrderBody {
  @extension("x-oapi-codegen-extra-tags", #{ validate: "required,gt=0" })
  quantity: int32;
}
oapi-codegen emits a validate:"required,gt=0" tag onto the generated field; from there, the pipeline above runs on it just the same.

Custom tags

Beyond the built-in rule set, you register your own with WithRegister and make it application-wide with server.WithValidator:

v := validate.New(validate.WithRegister(func(v *validator.Validate) {
    _ = v.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
        return slugRe.MatchString(fl.Field().String())
    })
}))

err := server.Run(ctx, cfg.Server, register, server.WithValidator(v))

From then on the validate:"slug" tag works everywhere: in the StrictValidator middleware and in manual Struct calls. A custom tag's message gets Translate's fallback form (failed rule slug); if you need something nicer, map it on the client side.

Customizing field names

The default json-tag naming can be replaced with WithTagNameFunc, for instance when you want the form tag in errors:

v := validate.New(validate.WithTagNameFunc(func(field reflect.StructField) string {
    name, _, _ := strings.Cut(field.Tag.Get("form"), ",")
    return name
}))

Outside the binding pipeline

Struct returns the same error shape anywhere, for checking service input, configuration or worker payloads:

if err := v.Struct(input); err != nil {
    return err // already a *httperr.Problem (422), ready for the handler chain to render
}

Raw validator.ValidationErrors (e.g. from a directly used validator instance) translate to the same field-error shape via Translate.

What struct tags don't cover

The validate struct tags (validate:"required,gt=0") cover syntactic/shape rules. Database-touching checks (uniqueness, for instance) are not a tag: they go to the service layer, surfacing as errs errors. Authorization is a separate concern again, handled by the policy layer, not by a validation tag.

With the kit

server.StrictValidator (see the server core) wraps exactly the *validate.Validator documented above: nothing about the validator itself changes when it runs inside a generated project versus a hand-rolled net/http handler, only where it's invoked.

Patterns used

  • Functional options directly on the wrapped type (Option func(*validator.Validate)): Design patterns.
  • Adapter + translation layer (Translate: validator.ValidationErrorshttperr.FieldError) between the validation library and the wire format.
  • httperr: Overview: the Problem/FieldError shape this page produces.
  • Error responses: the mapping row that reads a validation Problem.
  • The server core: StrictValidator, WithValidator, and where validation sits in the middleware chain.
  • Policies: authorization, the layer validation deliberately doesn't cover.
Copyright © 2026