Skip to content

Response Validation

wiki edited this page Sep 4, 2026 · 1 revision

Response validation

On by default: a handler's response is checked against the schema declared for the status it wrote.

func (r *GetUser) Responses() map[int]validation.BodySchema {
	return map[int]validation.BodySchema{
		200: validation.Scalar(UserResponse{}),
		404: validation.Scalar(rextension.Problem{}),
	}
}

Returning nil from Responses() skips it entirely for that route.

What it catches

A handler that answers with a shape its documentation does not describe. That is worth catching in staging, where it is a test failure, rather than in a client's parser.

validation.NewConfig(validation.WithValidateResponses(false)) // turn it off

Strict mode

validation.NewConfig(validation.WithStrictResponses(true))

With strict responses on, a handler writing a status that is not in Responses() produces a 500.

Off by default because it is a genuine behaviour change: every status your handlers can produce has to be documented, including the ones from error paths you have not thought about yet. Turned on, it is a strong guarantee — the documentation and the implementation cannot disagree — and it is much easier to adopt in a new service than to retrofit.

501 written by handler, not in Responses() → 500 to the client

The client is told only that something went wrong:

{"type":"urn:rex:problem:internal","title":"Internal Server Error","status":500,
 "detail":"the request could not be completed"}

The mismatch describes the server's own internals, so it goes to the log:

validation.MiddlewareConfig{Logger: r.Logger()}

Without a logger the specifics are simply lost, and the redaction rule becomes unmaintainable in practice. The extension sets it for you.

Adopting strict mode

  1. Turn on ValidateResponses (already the default) and run your test suite.
  2. Document every status your handlers actually write, including 4xx. rextension.Problem is the schema for all of them.
  3. Turn on WithStrictResponses(true) in staging first.
  4. Watch the log for strict-mode rejections before promoting.

A useful shortcut: document the problem responses once and reuse the map.

func problemResponses(codes ...int) map[int]validation.BodySchema {
	m := make(map[int]validation.BodySchema, len(codes))
	for _, c := range codes {
		m[c] = validation.Scalar(rextension.Problem{})
	}
	return m
}

func (r *GetUser) Responses() map[int]validation.BodySchema {
	m := problemResponses(400, 401, 403, 404, 500)
	m[200] = validation.Scalar(UserResponse{})
	return m
}

Re-encoding

The response is re-encoded through the negotiated codec, so a route declaring a UserResponse answers XML to a client that asked for it, without the handler knowing. See Codecs and Negotiation.

Cost

Response validation buffers and re-decodes the response body. That is fine for ordinary JSON payloads and wrong for anything streamed or large.

For a streaming endpoint, return nil from Responses() — the route is then passed through untouched, which is what you want for server-sent events or a file download.

Clone this wiki locally