diff --git a/README.md b/README.md index ed1e00e..bba6828 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,14 @@ e.Use(mw) // now all HTTP routes will be handled by the middleware, and any requests that are invalid will be rejected ``` +### The validated request body + +This middleware enables `openapi3filter.Options.KeepValidatedValue` by default. After a request body passes validation, the `c.Request().Body` seen by handlers implements `interface{ ValidatedValue() any }`, returning the decoded value that was validated (schema defaults included), and the body can still be read as usual. This lets a handler, such as an `oapi-codegen` strict server that supports it, bind exactly the value that was validated rather than decoding the body a second time, which closes gaps where a second decode could disagree with validation (for example, `encoding/json` matching object keys case-insensitively, so `{"role":"user","ROLE":"admin"}` validates as `role: user` but binds as `Role: "admin"`). + +To opt out, for instance to limit memory retention for very large request bodies, set `Options.DiscardValidatedValue: true`. This takes precedence over `Options.Options.KeepValidatedValue`. + +Any middleware that runs after the validator and replaces the request body (decompression, size limiting, logging wrappers, ...) hides the validated value from handlers, so such middleware belongs before the validator. + ## FAQs ### What versions of Echo does this support? diff --git a/oapi_validate.go b/oapi_validate.go index 8381d5f..f156c47 100644 --- a/oapi_validate.go +++ b/oapi_validate.go @@ -88,6 +88,16 @@ type Options struct { // Prefix is stripped from the request path before validation. This is useful when the API is mounted under a sub-path // (e.g. "/api") that isn't part of the OpenAPI spec's paths. The prefix must start with "/" if set. Prefix string + // 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 the middleware to validate that incoming requests match the given OpenAPI 3.x spec, allowing explicit configuration. @@ -127,7 +137,9 @@ func OapiRequestValidatorWithOptions(spec *openapi3.T, options *Options) echo.Mi } // ValidateRequestFromContext is called from the middleware above and actually does the work -// of validating a request. +// of validating a request. A nil options validates with the defaults, which, as +// with a non-nil options, include openapi3filter.Options.KeepValidatedValue (see +// Options.DiscardValidatedValue). func ValidateRequestFromContext(ctx echo.Context, router routers.Router, options *Options) *echo.HTTPError { req := ctx.Request() @@ -192,6 +204,7 @@ func ValidateRequestFromContext(ctx echo.Context, router routers.Router, options Request: req, PathParams: pathParams, Route: route, + Options: filterOptions(options), } // Pass the Echo context into the request validator, so that any callbacks @@ -199,7 +212,6 @@ func ValidateRequestFromContext(ctx echo.Context, router routers.Router, options requestContext := context.WithValue(context.Background(), EchoContextKey, ctx) //nolint:staticcheck if options != nil { - validationInput.Options = &options.Options validationInput.ParamDecoder = options.ParamDecoder requestContext = context.WithValue(requestContext, UserDataKey, options.UserData) //nolint:staticcheck } @@ -266,6 +278,20 @@ func GetUserData(c context.Context) any { return c.Value(UserDataKey) } +// filterOptions returns the openapi3filter.Options to validate a request with: +// a copy of options.Options, so the caller's struct is never modified, with +// KeepValidatedValue enabled unless options.DiscardValidatedValue is set. With +// nil options, KeepValidatedValue is enabled and everything else is left at +// its default. +func filterOptions(options *Options) *openapi3filter.Options { + if options == nil { + return &openapi3filter.Options{KeepValidatedValue: true} + } + filterOpts := options.Options + filterOpts.KeepValidatedValue = !options.DiscardValidatedValue + return &filterOpts +} + // attempt to get the skipper from the options whether it is set or not func getSkipperFromOptions(options *Options) echomiddleware.Skipper { if options == nil { diff --git a/oapi_validate_test.go b/oapi_validate_test.go index ca2c4fa..230bacf 100644 --- a/oapi_validate_test.go +++ b/oapi_validate_test.go @@ -740,6 +740,100 @@ func TestPrefixKeepValidatedValue(t *testing.T) { assert.True(t, called, "Handler should have been called") } +// TestKeepValidatedValueDefault checks that openapi3filter's +// KeepValidatedValue is enabled by default, so the handler's request body +// exposes the validated value, that DiscardValidatedValue turns it off, and +// that the caller's Options are not modified. +func TestKeepValidatedValueDefault(t *testing.T) { + const body = `{"name":"Marcin"}` + + tests := []struct { + name string + options *Options // nil means OapiRequestValidator(spec) + // wantValue is whether the handler's body should implement + // ValidatedValue(). + wantValue bool + }{ + { + name: "no options", + wantValue: true, + }, + { + name: "empty options", + options: &Options{}, + wantValue: true, + }, + { + name: "DiscardValidatedValue", + options: &Options{DiscardValidatedValue: true}, + wantValue: false, + }, + { + name: "DiscardValidatedValue overrides openapi3filter KeepValidatedValue", + options: &Options{ + DiscardValidatedValue: true, + Options: openapi3filter.Options{KeepValidatedValue: true}, + }, + wantValue: false, + }, + { + // The body is not validated, so there is no validated value to + // keep, but the request must still go through untouched. + name: "ExcludeRequestBody", + options: &Options{ + Options: openapi3filter.Options{ExcludeRequestBody: true}, + }, + wantValue: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec, err := openapi3.NewLoader().LoadFromData(testSchema) + require.NoError(t, err, "Error initializing OpenAPI spec") + + var callerOpts openapi3filter.Options + e := echo.New() + if tt.options == nil { + e.Use(OapiRequestValidator(spec)) + } else { + tt.options.SilenceServersWarning = true + callerOpts = tt.options.Options + e.Use(OapiRequestValidatorWithOptions(spec, tt.options)) + } + + called := false + e.POST("/resource", func(c echo.Context) error { + called = true + vv, ok := c.Request().Body.(interface{ ValidatedValue() any }) + if tt.wantValue { + if assert.True(t, ok, "request body should implement ValidatedValue(), got %T", c.Request().Body) { + assert.Equal(t, map[string]any{"name": "Marcin"}, vv.ValidatedValue()) + } + } else { + assert.False(t, ok, "request body should not implement ValidatedValue(), got %T", c.Request().Body) + } + data, err := io.ReadAll(c.Request().Body) + require.NoError(t, err) + assert.Equal(t, body, string(data)) + return c.NoContent(http.StatusNoContent) + }) + + r := httptest.NewRequest(http.MethodPost, "http://test.hostname/resource", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, r) + + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.True(t, called, "Handler should have been called") + if tt.options != nil { + assert.Equal(t, callerOpts.KeepValidatedValue, tt.options.Options.KeepValidatedValue, + "the caller's openapi3filter.Options should not be modified") + } + }) + } +} + // TestPrefixEncodedPathParams checks that a percent-encoded path parameter // still routes under a Prefix: URL.RawPath must be trimmed along with // URL.Path, and both restored for the handler.