diff --git a/README.md b/README.md index d9cf8ce..750467c 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,11 @@ Middleware for the [Fiber v3 web server](https://github.com/gofiber/fiber) for u > [!NOTE] > Using Fiber v2? You want [`oapi-codegen/fiber-middleware`](https://github.com/oapi-codegen/fiber-middleware) instead. +## Validated request body value + +By default, `OapiRequestValidator` and `OapiRequestValidatorWithOptions` enable kin-openapi's `openapi3filter.Options.KeepValidatedValue`, so the validated request body exposes the decoded value through a `ValidatedValue() any` method. This lets a handler, such as an oapi-codegen strict server, bind the exact value that passed validation instead of decoding the body a second time. Set `Options.DiscardValidatedValue` to `true` to turn this off, for example to limit memory retention for very large request bodies; it overrides `Options.Options.KeepValidatedValue`. + +> [!NOTE] +> This middleware currently validates a copy of the request (built with Fiber's `adaptor.ConvertRequest`), so Fiber handlers do not yet receive the validated value. The default and the `DiscardValidatedValue` option exist for consistency with the other oapi-codegen middlewares and for a future change that exposes the value to handlers. + Licensed under the Apache-2.0. diff --git a/oapi_validate.go b/oapi_validate.go index b7c098e..af4f9ca 100644 --- a/oapi_validate.go +++ b/oapi_validate.go @@ -60,6 +60,21 @@ 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 request body + // seen by handlers exposes the value that was validated through a + // ValidatedValue() method, 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. + // + // Note: this middleware currently validates a copy of the request, so fiber + // handlers do not yet observe the validated value; the setting is kept for + // consistency with the other oapi-codegen middlewares and for a future + // change that exposes it. + DiscardValidatedValue bool } // OapiRequestValidatorWithOptions creates a validator from a swagger object, with validation options @@ -117,6 +132,7 @@ func ValidateRequestFromContext(c fiber.Ctx, router routers.Router, options *Opt Request: r, PathParams: pathParams, Route: route, + Options: filterOptions(options), } // Pass the fiber context into the request validator, so that any callbacks @@ -124,7 +140,6 @@ func ValidateRequestFromContext(c fiber.Ctx, router routers.Router, options *Opt requestContext := context.WithValue(context.Background(), ctxKeyFiberContext{}, c) //nolint:staticcheck if options != nil { - requestValidationInput.Options = &options.Options requestValidationInput.ParamDecoder = options.ParamDecoder requestContext = context.WithValue(requestContext, ctxKeyUserData{}, options.UserData) //nolint:staticcheck } @@ -155,6 +170,18 @@ func ValidateRequestFromContext(c fiber.Ctx, router routers.Router, options *Opt return nil } +// filterOptions returns the openapi3filter.Options used to validate a request. +// It works on a copy, so the caller's Options are never mutated, and enables +// KeepValidatedValue unless Options.DiscardValidatedValue is set. +func filterOptions(options *Options) *openapi3filter.Options { + if options == nil { + return &openapi3filter.Options{KeepValidatedValue: true} + } + filterOpts := options.Options + filterOpts.KeepValidatedValue = !options.DiscardValidatedValue + return &filterOpts +} + // GetFiberContext gets the fiber context from within requests. It returns // nil if not found or wrong type. func GetFiberContext(c context.Context) fiber.Ctx { diff --git a/oapi_validate_test.go b/oapi_validate_test.go index e0140e0..65906b2 100644 --- a/oapi_validate_test.go +++ b/oapi_validate_test.go @@ -432,3 +432,153 @@ func TestOapiRequestValidatorWithOptionsMultiErrorAndCustomHandler(t *testing.T) called = false } } + +func TestFilterOptions(t *testing.T) { + t.Run("nil options keep the validated value", func(t *testing.T) { + got := filterOptions(nil) + require.NotNil(t, got) + assert.True(t, got.KeepValidatedValue) + }) + + t.Run("zero options keep the validated value", func(t *testing.T) { + got := filterOptions(&Options{}) + require.NotNil(t, got) + assert.True(t, got.KeepValidatedValue) + }) + + t.Run("DiscardValidatedValue turns it off", func(t *testing.T) { + got := filterOptions(&Options{DiscardValidatedValue: true}) + require.NotNil(t, got) + assert.False(t, got.KeepValidatedValue) + }) + + t.Run("DiscardValidatedValue overrides Options.KeepValidatedValue", func(t *testing.T) { + got := filterOptions(&Options{ + Options: openapi3filter.Options{KeepValidatedValue: true}, + DiscardValidatedValue: true, + }) + assert.False(t, got.KeepValidatedValue) + }) + + t.Run("other fields are preserved and the caller's struct is not mutated", func(t *testing.T) { + authFunc := func(context.Context, *openapi3filter.AuthenticationInput) error { return nil } + options := &Options{ + Options: openapi3filter.Options{ + ExcludeRequestBody: true, + MultiError: true, + SkipSettingDefaults: true, + AuthenticationFunc: authFunc, + }, + } + + got := filterOptions(options) + + assert.True(t, got.KeepValidatedValue) + assert.True(t, got.ExcludeRequestBody) + assert.True(t, got.MultiError) + assert.True(t, got.SkipSettingDefaults) + assert.NotNil(t, got.AuthenticationFunc) + + assert.NotSame(t, &options.Options, got, "filterOptions must return a copy") + assert.False(t, options.Options.KeepValidatedValue, "caller's Options must not be mutated") + assert.True(t, options.Options.ExcludeRequestBody) + }) +} + +// TestValidatedValueSetting checks that requests validate the same way with +// KeepValidatedValue on (the default) and off (DiscardValidatedValue), and +// that the fiber handler still sees the original request body either way. +func TestValidatedValueSetting(t *testing.T) { + swagger, err := openapi3.NewLoader().LoadFromData(testSchema) + require.NoError(t, err, "Error initializing swagger") + + configs := []struct { + name string + options *Options + }{ + {name: "nil options", options: nil}, + {name: "default options", options: &Options{}}, + {name: "DiscardValidatedValue", options: &Options{DiscardValidatedValue: true}}, + } + + requests := []struct { + name string + method string + url string + body interface{} + wantStatus int + wantCalled bool + }{ + { + name: "valid body", + method: http.MethodPost, + url: "https://deepmap.ai/resource", + body: map[string]interface{}{"name": "Marcin"}, + wantStatus: http.StatusNoContent, + wantCalled: true, + }, + { + name: "invalid body", + method: http.MethodPost, + url: "https://deepmap.ai/resource", + body: map[string]interface{}{"name": 7}, + wantStatus: http.StatusBadRequest, + wantCalled: false, + }, + { + name: "valid query parameter", + method: http.MethodGet, + url: "https://deepmap.ai/resource?id=50", + wantStatus: http.StatusNoContent, + wantCalled: true, + }, + { + name: "invalid query parameter", + method: http.MethodGet, + url: "https://deepmap.ai/resource?id=500", + wantStatus: http.StatusBadRequest, + wantCalled: false, + }, + } + + for _, cfg := range configs { + t.Run(cfg.name, func(t *testing.T) { + app := fiber.New() + app.Use(OapiRequestValidatorWithOptions(swagger, cfg.options)) + + var called bool + var handlerBody []byte + handler := func(c fiber.Ctx) error { + called = true + handlerBody = append([]byte(nil), c.Body()...) + return c.SendStatus(http.StatusNoContent) + } + app.Get("/resource", handler) + app.Post("/resource", handler) + + for _, tc := range requests { + t.Run(tc.name, func(t *testing.T) { + called = false + handlerBody = nil + + var res *http.Response + if tc.method == http.MethodPost { + res = doPost(t, app, tc.url, tc.body) + } else { + res = doGet(t, app, tc.url) + } + + assert.Equal(t, tc.wantStatus, res.StatusCode) + assert.Equal(t, tc.wantCalled, called) + + if tc.wantCalled && tc.body != nil { + want, err := json.Marshal(tc.body) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(handlerBody), + "handler must see the original request body") + } + }) + } + }) + } +}