A Rex extension for automatic request/response body validation, content negotiation, and pluggable codec support.
rextension-validation is a Rex extension that provides:
- Automatic request body decoding using a pluggable
Codecinterface - Struct validation via go-playground/validator/v10
- Content-Type checking — returns
415 Unsupported Media Typewhen the request body uses an unregistered content type - Accept header negotiation with quality values — returns
406 Not Acceptablewhen no registered codec matches - Response body validation with optional strict mode — returns
500 Internal Server Errorfor undocumented status codes - Union schemas —
OneOf,AnyOf,AllOffor advanced request/response contracts - Per-status-code response schemas — validate outgoing bodies per HTTP status
- Context helpers — retrieve decoded bodies and negotiated codecs in handlers
go get github.com/kryovyx/rextension-validation
Define a validated route with a Scalar request body:
package main import ( "net/http" "github.com/kryovyx/rex" "github.com/kryovyx/rex/route" validation "github.com/kryovyx/rextension-validation" ) // CreateUserRequest is the expected request body. type CreateUserRequest struct { Name string `json:"name" validate:"required,min=2"` Email string `json:"email" validate:"required,email"` } // CreateUserResponse is the response body. type CreateUserResponse struct { ID int `json:"id"` Name string `json:"name"` Email string `json:"email"` } // CreateUserRoute implements both route.Route and ValidatableRoute. type CreateUserRoute struct { route.Route } func (r *CreateUserRoute) RequestBody() validation.BodySchema { return validation.Scalar(CreateUserRequest{}) } func (r *CreateUserRoute) Responses() map[int]validation.BodySchema { return map[int]validation.BodySchema{ 201: validation.Scalar(CreateUserResponse{}), } } func main() { app := rex.New() // Add the validation extension app.WithOptions( validation.WithValidation(nil), ) // Register a validated route app.RegisterRoute(&CreateUserRoute{ Route: route.New("POST", "/users", func(ctx route.Context) { body, ok := validation.GetRequestBody[CreateUserRequest](ctx.Request()) if !ok { ctx.Text(http.StatusBadRequest, "missing body") return } resp := CreateUserResponse{ID: 1, Name: body.Name, Email: body.Email} codec := validation.GetAcceptCodec(ctx.Request()) data, _ := codec.Marshal(resp) ctx.Respond(http.StatusCreated, codec.ContentType(), data) }), }) if err := app.Run(); err != nil { panic(err) } }
A Codec encodes and decodes values for a specific content type. The built-in JSONCodec handles application/json.
// The Codec interface type Codec interface { ContentType() string // e.g., "application/json" Marshal(v interface{}) ([]byte, error) // Serialize Unmarshal(data []byte, v interface{}) error // Deserialize }
JSONCodec is registered by default. Add more codecs via configuration:
validation.WithValidation(validation.NewConfig( validation.WithCodec(myXMLCodec), validation.WithCodec(myYAMLCodec), ))
The first codec in the list serves as the default when the client does not specify an Accept header.
The middleware performs two content negotiation checks on every request to a ValidatableRoute:
| Check | Header | Failure | HTTP Status |
|---|---|---|---|
| Request body decoding | Content-Type |
Content type not registered | 415 Unsupported Media Type |
| Response encoding | Accept |
No registered codec matches | 406 Not Acceptable |
The Accept header supports quality values (q=...). For example, Accept: application/xml;q=0.9, application/json;q=1.0 prefers JSON.
Body schemas describe the shape of request and response bodies. Four schema kinds are available:
| Constructor | Kind | Description |
|---|---|---|
Scalar(v) |
SchemaScalar |
A single concrete type |
OneOf(vs...) |
SchemaOneOf |
Exactly one of the listed types must match |
AnyOf(vs...) |
SchemaAnyOf |
One or more of the listed types may match (first match wins) |
AllOf(vs...) |
SchemaAllOf |
All of the listed types must match (merged) |
// Single type validation.Scalar(CreateUserRequest{}) // Exactly one must match validation.OneOf(AdminRequest{}, UserRequest{}) // Any may match validation.AnyOf(JSONPayload{}, XMLPayload{}) // All must match (merged fields) validation.AllOf(BaseRequest{}, ExtendedFields{})
Struct validation uses go-playground/validator/v10 tags. Add validate struct tags to your request types:
type CreateUserRequest struct { Name string `json:"name" validate:"required,min=2,max=100"` Email string `json:"email" validate:"required,email"` Age int `json:"age" validate:"omitempty,gte=0,lte=150"` }
When validation fails, the middleware returns a structured 422 Unprocessable Entity response (see Error Responses).
Routes opt into validation by implementing the ValidatableRoute interface. Routes that do not implement it are passed through without validation.
type ValidatableRoute interface { // RequestBody returns the body schema for the request, or nil to skip. RequestBody() BodySchema // Responses returns a map of HTTP status code → body schema. // Return nil to skip response validation. Responses() map[int]BodySchema }
Example with multiple response codes:
func (r *GetUserRoute) RequestBody() validation.BodySchema { return nil // GET — no request body } func (r *GetUserRoute) Responses() map[int]validation.BodySchema { return map[int]validation.BodySchema{ 200: validation.Scalar(UserResponse{}), 404: validation.Scalar(ErrorResponse{}), } }
After the middleware processes a request, decoded values are stored in the request context. Two generic helpers retrieve them in handlers:
body, ok := validation.GetRequestBody[CreateUserRequest](ctx.Request()) if !ok { // No decoded body — route may not implement ValidatableRoute }
codec := validation.GetAcceptCodec(ctx.Request()) if codec != nil { data, _ := codec.Marshal(response) ctx.Respond(http.StatusOK, codec.ContentType(), data) }
When enabled (default), the middleware validates outgoing response bodies against the schemas declared in Responses(). The response is captured, validated, and then flushed to the client.
With StrictResponses enabled, any status code not present in the Responses() map causes a 500 Internal Server Error instead of passing through:
validation.WithValidation(validation.NewConfig( validation.WithStrictResponses(true), ))
| Scenario | StrictResponses: false (default) |
StrictResponses: true |
|---|---|---|
Status code in Responses() map |
Validate body against schema | Validate body against schema |
Status code not in Responses() map |
Pass through unvalidated | Return 500 Internal Server Error |
| Response body fails validation | Return 500 Internal Server Error |
Return 500 Internal Server Error |
Disable response validation entirely:
validation.WithValidation(validation.NewConfig( validation.WithValidateResponses(false), ))
All configuration is optional. Pass nil to WithValidation to use the defaults.
| Field | Type | Default | Description |
|---|---|---|---|
Codecs |
[]Codec |
[JSONCodec{}] |
Ordered list of registered codecs. The first codec is the default. |
StrictResponses |
bool |
false |
Return 500 for undocumented status codes. |
ValidateResponses |
bool |
true |
Enable response body validation against declared schemas. |
cfg := validation.NewConfig( validation.WithCodec(myXMLCodec), validation.WithStrictResponses(true), validation.WithValidateResponses(true), ) app.WithOptions(validation.WithValidation(cfg))
| Option | Description |
|---|---|
WithCodec(c) |
Appends a codec to the codec list. |
WithStrictResponses(strict) |
Enables or disables strict response checking. |
WithValidateResponses(validate) |
Enables or disables response body validation. |
Implement the Codec interface to support additional content types:
package main import "encoding/xml" // XMLCodec handles application/xml. type XMLCodec struct{} func (XMLCodec) ContentType() string { return "application/xml" } func (XMLCodec) Marshal(v interface{}) ([]byte, error) { return xml.Marshal(v) } func (XMLCodec) Unmarshal(data []byte, v interface{}) error { return xml.Unmarshal(data, v) }
Register it alongside the default JSON codec:
validation.WithValidation(validation.NewConfig( validation.WithCodec(XMLCodec{}), ))
Clients can now send Content-Type: application/xml and negotiate responses via Accept: application/xml.
When request body validation fails, the middleware returns a 422 Unprocessable Entity response with a structured JSON body:
{
"status": 422,
"message": "Validation failed",
"errors": [
{
"field": "Name",
"tag": "required",
"value": "",
"message": "field 'Name' failed on the 'required' tag"
},
{
"field": "Email",
"tag": "email",
"value": "not-an-email",
"message": "field 'Email' failed on the 'email' tag"
}
]
}The response types are:
type ValidationError struct { Field string `json:"field"` Tag string `json:"tag"` Value string `json:"value,omitempty"` Message string `json:"message"` } type ValidationErrorResponse struct { Status int `json:"status"` Message string `json:"message"` Errors []ValidationError `json:"errors"` }
- Use struct tags consistently — always pair
jsontags withvalidatetags to keep serialization and validation aligned. - Start without strict mode — enable
StrictResponsesonce all response codes are fully documented inResponses(). - Keep schemas in sync — when adding a new status code to a handler, add it to
Responses()as well. - Prefer
Scalarfor most routes — useOneOf/AnyOf/AllOfonly when your API genuinely accepts polymorphic payloads. - Register codecs once — add all codecs at startup via
WithCodec; avoid modifying the codec list after the application starts. - Use context helpers — always use
GetRequestBody[T]andGetAcceptCodecinstead of manually decoding; the middleware has already done the work. - Return the negotiated codec's content type — use
codec.ContentType()fromGetAcceptCodecin yourctx.Respondcalls so the response matches what the client asked for.
v0.2.0 → v0.3.0. MIGRATION.md is the upgrade guide for this
module, written to stand alone — it carries the dependency-ordered go get
sequence, every breaking change here, and what to verify afterwards. Other
modules of the framework each have their own; that file links to them.
The framework is in alpha, and external contributions open at v1.0.0.
Until then pull requests will be closed unmerged — but issues are very welcome.
Bug reports, questions and feature requests all feed into what v1.0.0 looks
like.
See CONTRIBUTING.md for the rules that will apply, and COMMIT-CONVENTIONS.md for the commit format.
This project is licensed under the MIT License — see the LICENSE file for details.
© 2026 Kryovyx