HTTP & Routing

Validation

Struct-tag-based request validation with go-playground/validator, at bind time on Fiber, as strict middleware on chi, with 422 problem responses.

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(): required semantics for nested structs follow the upcoming 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 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 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.

On your hand-written structs (Fiber handler DTOs, service input) 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 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))

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.ValidationErrorshttperr.FieldError) between the validation library and the wire format.
Copyright © 2026