diff --git a/README.md b/README.md index 3e161d5..4f749d6 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,11 @@ Middleware for the [Iris web server](https://github.com/kataras/iris/) for use with [oapi-codegen](https://github.com/oapi-codegen/oapi-codegen). Licensed under the Apache-2.0. + +## Validated request body + +The middleware enables kin-openapi's `openapi3filter.Options.KeepValidatedValue` by default. After a request body passes validation, the body that handlers see (`ctx.Request().Body`) exposes the decoded value that was validated through a `ValidatedValue() any` method, and remains readable with its original bytes. An [oapi-codegen](https://github.com/oapi-codegen/oapi-codegen) strict server can bind that value instead of decoding the body a second time, so the handler receives exactly what was validated. + +To turn this off, for instance to limit memory retention for very large request bodies, set `DiscardValidatedValue: true` in `Options`. This setting overrides `Options.Options.KeepValidatedValue`. + +Middleware that replaces the request body after the validator has run (decompression, size limiting, logging wrappers and the like) hides the validated value from handlers. Register such middleware before the validator. diff --git a/oapi_validate.go b/oapi_validate.go index 552593e..a30d18e 100644 --- a/oapi_validate.go +++ b/oapi_validate.go @@ -59,6 +59,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 @@ -112,9 +122,16 @@ func ValidateRequestFromContext(ctx iris.Context, router routers.Router, options requestContext := context.WithValue(context.Background(), IrisContextKey, ctx) //nolint:staticcheck if options != nil { - requestValidationInput.Options = &options.Options + // Copy the caller's openapi3filter options so that setting + // KeepValidatedValue does not mutate the caller's struct. + filterOptions := options.Options + filterOptions.KeepValidatedValue = !options.DiscardValidatedValue + requestValidationInput.Options = &filterOptions requestValidationInput.ParamDecoder = options.ParamDecoder requestContext = context.WithValue(requestContext, UserDataKey, options.UserData) //nolint:staticcheck + } else { + // No options were given: keep the validated body value by default. + requestValidationInput.Options = &openapi3filter.Options{KeepValidatedValue: true} } err = openapi3filter.ValidateRequest(requestContext, requestValidationInput) diff --git a/oapi_validate_test.go b/oapi_validate_test.go index c3f7ebd..2e671d0 100644 --- a/oapi_validate_test.go +++ b/oapi_validate_test.go @@ -443,3 +443,102 @@ 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 is nil to exercise OapiRequestValidator(spec). + options *Options + // wantValidatedValue is whether the handler should see a request body + // exposing ValidatedValue(). + wantValidatedValue bool + }{ + { + name: "no options", + options: nil, + wantValidatedValue: true, + }, + { + name: "empty options", + options: &Options{}, + wantValidatedValue: true, + }, + { + name: "discard validated value", + options: &Options{DiscardValidatedValue: true}, + wantValidatedValue: false, + }, + { + name: "exclude request body", + options: &Options{ + Options: openapi3filter.Options{ExcludeRequestBody: true}, + }, + // The body is not validated, so there is no validated value. + wantValidatedValue: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + i := iris.New() + if tc.options == nil { + i.Use(OapiRequestValidator(swagger)) + } else { + i.Use(OapiRequestValidatorWithOptions(swagger, tc.options)) + } + + called := false + var ( + isValidatedValuer bool + validatedValue any + bodyBytes []byte + readErr error + ) + i.Post("/resource", func(ctx iris.Context) { + called = true + body := ctx.Request().Body + var vv validatedValuer + vv, isValidatedValuer = body.(validatedValuer) + if isValidatedValuer { + validatedValue = vv.ValidatedValue() + } + bodyBytes, readErr = io.ReadAll(body) + ctx.StatusCode(http.StatusNoContent) + }) + + require.NoError(t, i.Build()) + + reqBody := struct { + Name string `json:"name"` + }{ + Name: "Marcin", + } + res := doPost(t, i, "http://deepmap.ai/resource", reqBody) + assert.Equal(t, http.StatusNoContent, res.Code) + require.True(t, called, "Handler should have been called") + + // The body must remain readable with the original bytes. + require.NoError(t, readErr) + assert.Equal(t, `{"name":"Marcin"}`, string(bodyBytes)) + + assert.Equal(t, tc.wantValidatedValue, isValidatedValuer, + "request body ValidatedValue() presence") + if tc.wantValidatedValue { + assert.Equal(t, map[string]any{"name": "Marcin"}, validatedValue) + } + + // The caller's openapi3filter options must not be mutated. + if tc.options != nil { + assert.False(t, tc.options.Options.KeepValidatedValue, + "caller's openapi3filter.Options should not be modified") + } + }) + } +}