Validation
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():requiredsemantics for nested structs follow the validator v11 default;- json-tag field names: field names in errors come from the
jsontag (customerEmail, notCustomerEmail), 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.
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;
}
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.ValidationErrors→httperr.FieldError) between the validation library and the wire format.
Related pages
- httperr: Overview: the
Problem/FieldErrorshape 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.