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) + }) + } +}