Skip to content

Request Validation

wiki edited this page Sep 4, 2026 · 1 revision

Request validation

Struct tags

Validation is go-playground/validator/v10, driven by validate tags:

type CreateUserRequest struct {
	Email    string    `json:"email"    validate:"required,email"`
	Name     string    `json:"name"     validate:"required,min=2,max=100"`
	Age      int       `json:"age"      validate:"gte=18,lte=120"`
	Website  string    `json:"website"  validate:"omitempty,url"`
	Role     string    `json:"role"     validate:"required,oneof=admin user guest"`
	Tags     []string  `json:"tags"     validate:"max=10,dive,min=1"`
	Address  *Address  `json:"address"  validate:"omitempty"`
}

type Address struct {
	Street string `json:"street" validate:"required"`
	Postal string `json:"postal" validate:"required,len=5,numeric"`
}

Nested structs are validated automatically. dive applies the rules after it to each element of a slice or map.

Common tags: required, omitempty, email, url, uuid, min/max, len, gte/lte, oneof, eqfield, numeric, alphanum. The full set is in the validator/v10 documentation.

The 422

{
  "type": "urn:rex:problem:validation-failed",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "the request body failed validation",
  "errors": [
    {"field": "email", "rule": "email", "message": "must be a valid email address"},
    {"field": "age",   "rule": "gte",   "message": "must be at least 18", "value": "12"}
  ]
}

Content-Type: application/problem+json.

  • field is the JSON name, not the Go field name — it is the client's own term for the thing.
  • rule is the constraint that failed. It used to be called tag, which described the Go struct tag that produced the constraint rather than the constraint itself.
  • value is omitted for anything that could be a credential.

errors is an RFC 9457 extension member, which §3.2 permits; a consumer that does not know it ignores it.

Reading the decoded body

func createUser(ctx rxroute.Context) {
	body, ok := validation.GetRequestBody[CreateUserRequest](ctx.Request())
	if !ok {
		// Unreachable when the route declares a request schema — the
		// middleware would have answered 422 already.
		rextension.WriteProblem(ctx.ResponseWriter(), ctx.Request(),
			400, rextension.ProblemBadRequest, "a request body is required")
		return
	}
	_ = ctx.JSON(201, save(body))
}

The type parameter must match the type in the schema. A mismatch returns the zero value and false, not a panic.

Unknown fields are rejected

By default, a body carrying a member the schema does not declare is a 422.

{"email": "a@b.com", "nmae": "Ada"}
{
  "type": "urn:rex:problem:validation-failed",
  "status": 422,
  "detail": "the request body failed validation",
  "errors": [{"field": "nmae", "rule": "unknown", "message": "unrecognised field"}]
}

The field name comes from the decoder's JSON pointer, not from its error text. The text reads:

json: cannot unmarshal JSON string into Go api.CreateUserRequest: unknown object member name "nmae"

which names an internal Go type — that belongs in a log, not in a response. The member name is the client's own input, so echoing it is both safe and the only genuinely useful part. validation.UnknownFieldName(err) is the helper that extracts it.

Why rejection is the default

An unknown member is almost always a client mistake: a misspelled field, a stale integration, a field renamed on one side only. Accepting it silently is what lets that mistake reach production looking like a success — the request returns 200, the field is dropped, and nobody learns anything until the missing data is noticed downstream.

The configuration field is phrased as a negative (AllowUnknownFields) so permitting them is something you have to write. That also matters because a partial struct literal is used verbatim: the zero value has to be the safe one, or &Config{Codecs: …} would quietly opt out.

Turning it off

validation.NewConfig(validation.WithAllowUnknownFields())

A real weakening, for exactly the reason above. Do it for an established client you cannot change.

It needs a codec that can enforce it

type StrictCodec interface {
	Codec
	UnmarshalStrict(data []byte, v interface{}) error
}

With rejection enabled, a registered codec that does not implement it fails the boot:

validation: codec "application/xml" cannot reject unknown members.
Implement StrictCodec.UnmarshalStrict, or enable WithAllowUnknownFields().

Decoding leniently while the configuration says members are rejected is the "configured but inoperative" failure this whole interface exists to avoid — invisible in exactly the way that matters: requests succeed, so nothing looks wrong. validation.ValidateStrictness(codecs) is the check, and the error names both remedies because either is legitimate.

Body size

Body limits are the router's job, not this extension's. A body over the limit is a 413 from the router before the validation middleware sees it. See rex → TLS and listener limits.

Custom validation

For rules that are not expressible as tags, validate in the handler after decoding and answer with a problem document:

if body.StartsAt.After(body.EndsAt) {
	rextension.NewProblem(422, rextension.ProblemValidationFailed,
			"the request body failed validation").
		WithErrors(rextension.FieldError{
			Field: "starts_at", Rule: "before", Message: "must be before ends_at",
		}).
		Write(ctx.ResponseWriter(), ctx.Request())
	return
}

Using the same document shape keeps the client parsing one format.

Clone this wiki locally