Validation
Request validation in the kit is declarative: you write rules as tags on struct fields, and the chassis handles execution and error rendering. The engine is go-playground/validator v10 (Go's de facto standard validator), and 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/gpsystem/validate"
The Validator type
func New(opts ...Option) *Validator
func (v *Validator) Validate(out any) error // implements fiber.StructValidator
func (v *Validator) Struct(s any) error // the same, for use outside the binding pipeline
func Translate(verrs validator.ValidationErrors) []httperr.FieldError
New builds the validator with two kit-fixed defaults:
WithRequiredStructEnabled():requiredsemantics for nested structs follow the upcoming 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 per engine
The same validator runs on both engines, with the same 422 output; only the execution point differs:
// fiberx.Run puts validate.New() into fiber.Config.StructValidator.
// From then on EVERY c.Bind().Body(&dto) call validates, both your
// hand-written handlers and the generated strict server (which binds
// through ctx.Bind().Body()). On failure the bind returns an error that
// becomes a 422 problem via the central error handler.
app.Post("/orders", func(c fiber.Ctx) error {
var req placeOrderBody
if err := c.Bind().Body(&req); err != nil {
return err // already a *httperr.Problem (422)
}
// ...
})
// The net/http strict server decodes with json.Decoder, without
// validation, so there, validation is strict middleware: chix.StrictValidator
// validates the fully bound request object (parameters + body) before the
// handler. The generated register.go wires it automatically:
shopapigen.NewStrictHandlerWithOptions(apiHandler,
[]shopapigen.StrictMiddlewareFunc{
chix.StrictValidator[shopapigen.StrictHandlerFunc](nil),
// ...
},
shopapigen.StrictHTTPServerOptions{ /* httperr hooks */ })
The nil argument on the chi side is deliberate: StrictValidator(nil) resolves the validator at request time: first looking for the one set via chix.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: the chi engine.
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, regardless of engine:
{
"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 pipelines above run 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 the engine's WithValidator option:
v := validate.New(validate.WithRegister(func(v *validator.Validate) {
_ = v.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
return slugRe.MatchString(fl.Field().String())
})
}))
err := fiberx.Run(ctx, cfg.Server, register, fiberx.WithValidator(v))
err := chix.Run(ctx, cfg.Server, register, chix.WithValidator(v))
From then on the validate:"slug" tag works everywhere: in bind-time validation, in the StrictValidator 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 kit's 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 (e.g. uniqueness) are not a tag: they go to the service layer, surfacing as errs errors.
| custom rule class | WithRegister + RegisterValidation |
The main difference is one of philosophy: a FormRequest is a request class carrying rules, authorization and messages; here, tags validate shape only. Authorization belongs to the policy layer, business invariants (stock, uniqueness) to the service layer.
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.