From 1fb8cf3d4e9aed231fbd09a97b4d6cd56608feb0 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 23 Oct 2026 00:18:41 -0700 Subject: [PATCH] Keep the validated request body value and expose it via c.Locals Enable openapi3filter.Options.KeepValidatedValue by default, as the other oapi-codegen validation middlewares do, with Options.DiscardValidatedValue as the opt-out. Fiber does not run on net/http, so the validator works on a converted copy of the request and the value it leaves on that copy's body is not reachable from handlers. After validation the middleware now copies the value into the Fiber context under ValidatedBodyLocalsKey ("x-oapi-codegen-validated-body"), readable with ValidatedBody(c). oapi-codegen strict Fiber servers bind that value with exact member-name matching instead of decoding the body a second time, which closes the gap where encoding/json's case-insensitive matching let an unvalidated "ROLE" member bind to `role`. The key is defined independently in the generated code so it does not import this module; keep the two in sync. The body bytes seen by handlers are unchanged. The caller's options struct is copied before the default is applied. Co-Authored-By: Claude Fable 5.1 --- README.md | 6 ++++ oapi_validate.go | 43 ++++++++++++++++++++++++- oapi_validate_test.go | 74 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ca1807b..4703e98 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,10 @@ Middleware for the [Fiber web server](https://github.com/gofiber/fiber) for use with [deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen). +## Validated request body + +The middleware enables kin-openapi's `openapi3filter.Options.KeepValidatedValue` by default. Fiber does not run on `net/http`, so the validator works on a converted copy of the request; after a request body passes validation, the middleware stores the decoded value that was validated in the Fiber context under `ValidatedBodyLocalsKey` (`x-oapi-codegen-validated-body`), readable with `ValidatedBody(c)`. The body bytes seen by the handler (`c.Body()`) are unchanged. oapi-codegen strict servers that support it bind that value instead of decoding the body a second time, so the handler sees exactly what was validated (for example, `encoding/json`'s case-insensitive member matching can no longer make a request validate as one value but bind as another). + +To opt out, for instance to limit memory retention for very large request bodies, set `Options.DiscardValidatedValue = true`. This setting overrides `Options.Options.KeepValidatedValue`. + Licensed under the Apache-2.0. diff --git a/oapi_validate.go b/oapi_validate.go index f2ef893..cd779d2 100644 --- a/oapi_validate.go +++ b/oapi_validate.go @@ -60,6 +60,34 @@ type Options struct { ParamDecoder openapi3filter.ContentParameterDecoder UserData interface{} MultiErrorHandler MultiErrorHandler + // DiscardValidatedValue turns off openapi3filter.Options.KeepValidatedValue, + // which this middleware enables by default. With it enabled, the decoded + // request body value that passed validation is stored in the Fiber context + // under ValidatedBodyLocalsKey, so a handler (for example an oapi-codegen + // strict server) can bind that value instead of decoding the body a second + // time. Set this to drop the decoded value after validation, for instance to + // limit memory retention for very large request bodies. + // + // Options.Options.KeepValidatedValue is overridden by this setting. + DiscardValidatedValue bool +} + +// ValidatedBodyLocalsKey is the c.Locals key under which the middleware stores +// the decoded request body value that passed validation (see +// Options.DiscardValidatedValue). The value is what kin-openapi validated: +// nil, bool, string, json.Number, []any or map[string]any. +// +// Strict servers generated by oapi-codegen look the value up under this same +// key, where it is defined independently so generated code does not import +// this module. Keep the two in sync. +const ValidatedBodyLocalsKey = "x-oapi-codegen-validated-body" + +// ValidatedBody returns the request body value that passed validation, as +// stored by the middleware under ValidatedBodyLocalsKey, and whether one was +// stored for this request. +func ValidatedBody(c *fiber.Ctx) (any, bool) { + v := c.Locals(ValidatedBodyLocalsKey) + return v, v != nil } // OapiRequestValidatorWithOptions creates a validator from a swagger object, with validation options @@ -124,9 +152,15 @@ func ValidateRequestFromContext(c *fiber.Ctx, router routers.Router, options *Op requestContext := context.WithValue(context.Background(), ctxKeyFiberContext{}, c) //nolint:staticcheck if options != nil { - requestValidationInput.Options = &options.Options + // Copy the openapi3filter options so the caller's struct is not + // mutated when applying our KeepValidatedValue default. + filterOptions := options.Options + filterOptions.KeepValidatedValue = !options.DiscardValidatedValue + requestValidationInput.Options = &filterOptions requestValidationInput.ParamDecoder = options.ParamDecoder requestContext = context.WithValue(requestContext, ctxKeyUserData{}, options.UserData) //nolint:staticcheck + } else { + requestValidationInput.Options = &openapi3filter.Options{KeepValidatedValue: true} } err = openapi3filter.ValidateRequest(requestContext, requestValidationInput) @@ -152,6 +186,13 @@ func ValidateRequestFromContext(c *fiber.Ctx, router routers.Router, options *Op return fmt.Errorf("error validating request: %w", err) } } + + // The validator ran on a net/http copy of the request, so the validated + // value it left on that copy's body has to be carried over to the Fiber + // context by hand for handlers to reach it. + if vb, ok := r.Body.(interface{ ValidatedValue() any }); ok { + c.Locals(ValidatedBodyLocalsKey, vb.ValidatedValue()) + } return nil } diff --git a/oapi_validate_test.go b/oapi_validate_test.go index e19a893..7b3d256 100644 --- a/oapi_validate_test.go +++ b/oapi_validate_test.go @@ -432,3 +432,77 @@ func TestOapiRequestValidatorWithOptionsMultiErrorAndCustomHandler(t *testing.T) called = false } } + +func TestOapiRequestValidatorKeepValidatedValue(t *testing.T) { + swagger, err := openapi3.NewLoader().LoadFromData(testSchema) + require.NoError(t, err, "Error initializing swagger") + + tests := []struct { + name string + options *Options + wantValidated bool + }{ + { + name: "nil options keep the validated value", + options: nil, + wantValidated: true, + }, + { + name: "empty options keep the validated value", + options: &Options{}, + wantValidated: true, + }, + { + name: "DiscardValidatedValue drops the validated value", + options: &Options{ + DiscardValidatedValue: true, + // DiscardValidatedValue overrides the openapi3filter setting. + Options: openapi3filter.Options{KeepValidatedValue: true}, + }, + wantValidated: false, + }, + { + name: "ExcludeRequestBody skips body validation", + options: &Options{ + Options: openapi3filter.Options{ExcludeRequestBody: true}, + }, + wantValidated: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := fiber.New() + if tt.options == nil { + app.Use(OapiRequestValidator(swagger)) + } else { + app.Use(OapiRequestValidatorWithOptions(swagger, tt.options)) + } + + var got any + var found bool + var rawBody string + app.Post("/resource", func(c *fiber.Ctx) error { + got, found = ValidatedBody(c) + rawBody = string(c.Body()) + return c.SendStatus(http.StatusNoContent) + }) + + body := struct { + Name string `json:"name"` + }{Name: "Marcin"} + res := doPost(t, app, "https://deepmap.ai/resource", body) + require.Equal(t, http.StatusNoContent, res.StatusCode) + // The original body is untouched for the handler either way. + assert.JSONEq(t, `{"name":"Marcin"}`, rawBody) + + if !tt.wantValidated { + assert.False(t, found) + assert.Nil(t, got) + return + } + require.True(t, found, "validated value should be stored under ValidatedBodyLocalsKey") + assert.Equal(t, map[string]any{"name": "Marcin"}, got) + }) + } +}