diff --git a/README.md b/README.md index 35121d2..feb659f 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,12 @@ Middleware for the [Gin web server](https://github.com/gin-gonic/gin) for use with [oapi-codegen/oapi-codegen](https://github.com/oapi-codegen/oapi-codegen). +## Validated request body + +The middleware enables kin-openapi's `openapi3filter.Options.KeepValidatedValue` by default. After a request body passes validation, `c.Request.Body` stays readable (with the original bytes, unless kin-openapi rewrote it to apply schema defaults) and additionally implements `interface{ ValidatedValue() any }`, returning the decoded value that was validated. oapi-codegen strict servers that support it can 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`. + +Any middleware placed after the validator that replaces `c.Request.Body` (decompression, size limiting, logging wrappers and the like) hides the validated value from handlers. Register such middleware before the validator. + Licensed under the Apache-2.0. diff --git a/oapi_validate.go b/oapi_validate.go index 871d8ff..f1fd26f 100644 --- a/oapi_validate.go +++ b/oapi_validate.go @@ -73,6 +73,16 @@ type Options struct { MultiErrorHandler MultiErrorHandler // SilenceServersWarning allows silencing a warning for https://github.com/deepmap/oapi-codegen/issues/882 that reports when an OpenAPI spec has `spec.Servers != nil` SilenceServersWarning bool + // 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. + DiscardValidatedValue bool } // OapiRequestValidatorWithOptions creates a validator from a swagger object, with validation options @@ -140,9 +150,15 @@ func ValidateRequestFromContext(c *gin.Context, router routers.Router, options * requestContext := context.WithValue(context.Background(), GinContextKey, c) //nolint:staticcheck if options != nil { - validationInput.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 + validationInput.Options = &filterOptions validationInput.ParamDecoder = options.ParamDecoder requestContext = context.WithValue(requestContext, UserDataKey, options.UserData) //nolint:staticcheck + } else { + validationInput.Options = &openapi3filter.Options{KeepValidatedValue: true} } err = openapi3filter.ValidateRequest(requestContext, validationInput) diff --git a/oapi_validate_test.go b/oapi_validate_test.go index 5832286..9b4eb3c 100644 --- a/oapi_validate_test.go +++ b/oapi_validate_test.go @@ -439,3 +439,101 @@ 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") + + type validatedValuer interface { + ValidatedValue() any + } + + 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{SilenceServersWarning: true}, + wantValidated: true, + }, + { + name: "DiscardValidatedValue drops the validated value", + options: &Options{ + SilenceServersWarning: true, + DiscardValidatedValue: true, + // DiscardValidatedValue overrides the openapi3filter setting. + Options: openapi3filter.Options{KeepValidatedValue: true}, + }, + wantValidated: false, + }, + { + name: "ExcludeRequestBody skips body validation", + options: &Options{ + SilenceServersWarning: true, + Options: openapi3filter.Options{ExcludeRequestBody: true}, + }, + wantValidated: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var originalFilterOptions openapi3filter.Options + if tt.options != nil { + originalFilterOptions = tt.options.Options + } + + g := gin.New() + if tt.options == nil { + g.Use(OapiRequestValidator(swagger)) + } else { + g.Use(OapiRequestValidatorWithOptions(swagger, tt.options)) + } + + called := false + var hasValidated bool + var validated any + var bodyBytes []byte + var bound map[string]any + g.POST("/resource", func(c *gin.Context) { + called = true + if vv, ok := c.Request.Body.(validatedValuer); ok { + hasValidated = true + validated = vv.ValidatedValue() + } + var readErr error + bodyBytes, readErr = io.ReadAll(c.Request.Body) + require.NoError(t, readErr) + require.NoError(t, json.Unmarshal(bodyBytes, &bound)) + c.AbortWithStatus(http.StatusNoContent) + }) + + body := map[string]any{"name": "Marcin"} + rec := doPost(t, g, "http://deepmap.ai/resource", body) + assert.Equal(t, http.StatusNoContent, rec.Code) + require.True(t, called, "Handler should have been called") + + wantBytes, err := json.Marshal(body) + require.NoError(t, err) + assert.Equal(t, string(wantBytes), string(bodyBytes), "body should still be readable with the original bytes") + assert.Equal(t, body, bound) + + assert.Equal(t, tt.wantValidated, hasValidated) + if tt.wantValidated { + assert.Equal(t, map[string]any{"name": "Marcin"}, validated) + } + + if tt.options != nil { + assert.Equal(t, originalFilterOptions.KeepValidatedValue, tt.options.Options.KeepValidatedValue, + "the caller's openapi3filter.Options must not be mutated") + } + }) + } +}