diff --git a/README.md b/README.md index ca1807b..0a9a5fb 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,23 @@ Middleware for the [Fiber web server](https://github.com/gofiber/fiber) for use with [deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen). +## Options + +`OapiRequestValidatorWithOptions` accepts an `Options` struct whose `Options` +field is passed through to kin-openapi's `openapi3filter`. + +This middleware enables `openapi3filter.Options.KeepValidatedValue` by default, +which keeps the decoded, validated request body on the validated request behind +a `ValidatedValue()` method, so that a handler such as an oapi-codegen strict +server can bind exactly the value that was validated rather than decoding the +body a second time. Set `Options.DiscardValidatedValue` to `true` to turn this +off, for instance to limit memory retention for very large request bodies; it +overrides any `KeepValidatedValue` set in `Options.Options`. + +Note that fiber handlers do not receive the validated value yet: this +middleware validates a `net/http` copy of the request converted from the fiber +context, so nothing kept on that copy reaches the handler. The default is set +for consistency with the other oapi-codegen middlewares and for a future change +that exposes the value. + Licensed under the Apache-2.0. diff --git a/oapi_validate.go b/oapi_validate.go index f2ef893..91b9340 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 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 +} + +// filterOptions returns the openapi3filter.Options to validate with: a copy of +// options.Options (or the zero value when options is nil) with +// KeepValidatedValue set according to options.DiscardValidatedValue. The +// caller's Options are never modified. +func filterOptions(options *Options) *openapi3filter.Options { + if options == nil { + return &openapi3filter.Options{KeepValidatedValue: true} + } + filterOpts := options.Options + filterOpts.KeepValidatedValue = !options.DiscardValidatedValue + return &filterOpts } // OapiRequestValidatorWithOptions creates a validator from a swagger object, with validation options @@ -117,6 +145,7 @@ func ValidateRequestFromContext(c *fiber.Ctx, router routers.Router, options *Op Request: r, PathParams: pathParams, Route: route, + Options: filterOptions(options), } // Pass the fiber context into the request validator, so that any callbacks @@ -124,7 +153,6 @@ 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 requestValidationInput.ParamDecoder = options.ParamDecoder requestContext = context.WithValue(requestContext, ctxKeyUserData{}, options.UserData) //nolint:staticcheck } diff --git a/oapi_validate_test.go b/oapi_validate_test.go index e19a893..bd74ec3 100644 --- a/oapi_validate_test.go +++ b/oapi_validate_test.go @@ -432,3 +432,168 @@ 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) { + options := &Options{ + Options: openapi3filter.Options{ + ExcludeRequestBody: true, + IncludeResponseStatus: true, + MultiError: true, + SkipSettingDefaults: true, + }, + } + got := filterOptions(options) + assert.True(t, got.KeepValidatedValue) + assert.True(t, got.ExcludeRequestBody) + assert.True(t, got.IncludeResponseStatus) + assert.True(t, got.MultiError) + assert.True(t, got.SkipSettingDefaults) + + assert.NotSame(t, &options.Options, got) + assert.False(t, options.Options.KeepValidatedValue, "caller's Options must not be modified") + assert.False(t, options.DiscardValidatedValue) + }) +} + +// TestValidatedValueOption checks that request validation behaves the same +// whether the validated value is kept (the default) or discarded. Fiber +// handlers cannot observe the validated value yet, because validation runs on +// a converted copy of the request, so this only checks what is observable: +// valid requests reach the handler with their body intact, invalid ones are +// rejected. +func TestValidatedValueOption(t *testing.T) { + swagger, err := openapi3.NewLoader().LoadFromData(testSchema) + require.NoError(t, err, "Error initializing swagger") + + tests := []struct { + name string + middleware func() fiber.Handler + }{ + { + name: "default without options", + middleware: func() fiber.Handler { return OapiRequestValidator(swagger) }, + }, + { + name: "default with zero options", + middleware: func() fiber.Handler { return OapiRequestValidatorWithOptions(swagger, &Options{}) }, + }, + { + name: "DiscardValidatedValue", + middleware: func() fiber.Handler { + return OapiRequestValidatorWithOptions(swagger, &Options{DiscardValidatedValue: true}) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := fiber.New() + app.Use(tt.middleware()) + + called := false + var gotBody []byte + app.Get("/resource", func(c *fiber.Ctx) error { + called = true + return nil + }) + app.Post("/resource", func(c *fiber.Ctx) error { + called = true + gotBody = append([]byte(nil), c.Body()...) + return c.SendStatus(http.StatusNoContent) + }) + + // A valid query parameter passes. + { + res := doGet(t, app, "https://deepmap.ai/resource?id=50") + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.True(t, called, "Handler should have been called") + called = false + } + + // An out-of-spec query parameter is rejected. + { + res := doGet(t, app, "https://deepmap.ai/resource?id=500") + assert.Equal(t, http.StatusBadRequest, res.StatusCode) + assert.False(t, called, "Handler should not have been called") + called = false + } + + // A valid body passes and reaches the handler unchanged. + { + body := map[string]any{"name": "Marcin"} + res := doPost(t, app, "https://deepmap.ai/resource", body) + assert.Equal(t, http.StatusNoContent, res.StatusCode) + assert.True(t, called, "Handler should have been called") + assert.JSONEq(t, `{"name":"Marcin"}`, string(gotBody)) + called = false + gotBody = nil + } + + // A body that violates the schema is rejected. + { + body := map[string]any{"name": 7} + res := doPost(t, app, "https://deepmap.ai/resource", body) + assert.Equal(t, http.StatusBadRequest, res.StatusCode) + assert.False(t, called, "Handler should not have been called") + called = false + } + }) + } +} + +// TestValidatedValueOptionPreservesUnexportedOptions checks that copying the +// caller's openapi3filter.Options keeps settings that are only reachable +// through methods, such as the custom schema error function. +func TestValidatedValueOptionPreservesUnexportedOptions(t *testing.T) { + swagger, err := openapi3.NewLoader().LoadFromData(testSchema) + require.NoError(t, err, "Error initializing swagger") + + options := &Options{ + ErrorHandler: func(c *fiber.Ctx, message string, statusCode int) { + _ = c.Status(statusCode).SendString(message) + }, + } + options.Options.WithCustomSchemaErrorFunc(func(*openapi3.SchemaError) string { + return "custom schema error" + }) + + app := fiber.New() + app.Use(OapiRequestValidatorWithOptions(swagger, options)) + app.Post("/resource", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusNoContent) + }) + + res := doPost(t, app, "https://deepmap.ai/resource", map[string]any{"name": 7}) + assert.Equal(t, http.StatusBadRequest, res.StatusCode) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "custom schema error") + assert.False(t, options.Options.KeepValidatedValue, "caller's Options must not be modified") +}